From 272e23891c41e2d16bd616473b4fff00b0c4f1ab Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 12:51:45 +0300 Subject: [PATCH 01/20] feat(scenarios): add support for Kamino programs (#5) * feat(scenarios): extend Kamino support across all six programs * fix(scenarios): stop persisted overrides re-fetching the account every slot Addresses two review comments. A persisted override was re-queued with fetch_before_use intact, so every following slot pulled the whole account from mainnet again: one RPC per slot per override, and any field the override does not write was reset to mainnet's value, discarding what local transactions had written to it. fetch_before_use is now cleared on the re-queue, but only after the write succeeds, so a failed apply still retries next slot with the fetch. The re-queue replaces a copy of itself already queued for that slot instead of bailing out, which keeps one entry per id. persist also gains the ts-bindings attribute its sibling fetch_before_use already had, and the regenerated OverrideInstance.ts exposes it - the field was previously absent from the TS SDK entirely. * Move to integration tests and fix final comments * Introduce 'persist' property to the rpc and update Readme.md with large numbers support * Add workflow * Move workflow to the folder * Fix duplication in templates * Fix integration tests * Fix bug * fix: stop persisted overrides re-fetching every slot * fix: retry fetch_before_use after a failed remote fetch * fix: retry fetch_before_use when the remote has no account yet * fix: retire fetch_before_use only once the request is met * fix: check slot arithmetic when scheduling overrides * Remove workflow --- .gitignore | 2 + crates/core/Cargo.toml | 2 + crates/core/src/scenarios/README.md | 30 +- .../kamino-liquidation-arbitrage.json | 58 +- .../src/scenarios/protocols/kamino/README.md | 275 ++ .../protocols/kamino/farms/v1/idl.json | 885 +++++ .../protocols/kamino/farms/v1/overrides.yaml | 219 ++ .../protocols/kamino/liquidity/v1/idl.json | 3276 +++++++++++++++++ .../kamino/liquidity/v1/overrides.yaml | 231 ++ .../protocols/kamino/scope/v1/idl.json | 1590 ++++++++ .../protocols/kamino/scope/v1/overrides.yaml | 127 + .../protocols/kamino/swap/v1/idl.json | 546 +++ .../protocols/kamino/swap/v1/overrides.yaml | 114 + .../scenarios/protocols/kamino/v1/idl.json | 1936 ++++++++-- .../protocols/kamino/v1/overrides.yaml | 603 ++- .../protocols/kamino/vault/v1/idl.json | 1781 +++++++++ .../protocols/kamino/vault/v1/overrides.yaml | 224 ++ crates/core/src/scenarios/registry.rs | 416 ++- crates/core/src/surfnet/svm.rs | 1011 ++++- crates/core/src/tests/kamino/mod.rs | 753 ++++ crates/core/src/tests/mod.rs | 2 + .../kit/generated/OverrideInstance.ts | 4 + crates/types/src/rpc_endpoints.json | 1 + crates/types/src/scenarios.rs | 153 +- 24 files changed, 13873 insertions(+), 366 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/kamino/README.md create mode 100644 crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json create mode 100644 crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml create mode 100644 crates/core/src/tests/kamino/mod.rs diff --git a/.gitignore b/.gitignore index 360c85527..ba528aa5a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ txtx.yml # ts-rs default export dir (real output lives in crates/sdk-node/surfpool-sdk/kit/generated) crates/types/bindings/ + +.idea diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6838097cd..fb9b300de 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -126,5 +126,7 @@ sbpf-debugger = ["litesvm/sbpf-debugger"] sqlite = ["surfpool-db/sqlite"] postgres = ["surfpool-db/postgres"] ignore_tests_ci = [] +# Tests that fetch live mainnet accounts; off by default because they need a network. +integration-tests = [] register-tracing = ["litesvm/register-tracing"] prometheus = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-prometheus", "dep:prometheus", "dep:axum"] diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 4368f2b85..f468626ed 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -16,7 +16,7 @@ Protocols that are natively supported by Surfpool will have their IDLs included - **Pyth v2** - Price oracle with 4 price feed templates (SOL/USD, BTC/USD, ETH/BTC, ETH/USD) - **Jupiter v6** - DEX aggregator with TokenLedger manipulation template - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template -- **Kamino v1.x** – Lending protocol with Reserve liquidity, risk config, and Obligation health override templates +- **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) - **Drift v2** - Perp and spot markets, user state, and global state For custom protocols, an IDL can be registered at runtime using the [`surfnet_registerIdl`](https://docs.surfpool.run/rpc/cheatcodes#surfnet-registeridl) RPC cheatcode. @@ -26,6 +26,34 @@ Scenarios can be registered at runtime using the [`surfnet_registerScenario`](ht This cheatcode takes in a scenario definition in JSON format, which includes the scenario name, description, and a list of overrides to apply to accounts. Each override contains a map of the field in the account to override (as indexed in the IDL), and the value to apply for that key. +Field keys use dot notation. Segments address struct fields by name and array elements by +zero-based index, so `liquidity.total_available_amount`, `deposits.0.deposited_amount` and +`config.borrow_rate_curve.points.3.borrow_rate_bps` are all valid. Supplying a composite value (a +whole struct or array) also works, but it must be **complete** - every field of every element, +padding included - because the account is re-encoded with Borsh. An out-of-range index or a +non-numeric segment on an array is a hard error, never a silent write elsewhere. + +By default an override applies to exactly one slot. Set `"persist": true` and it is re-applied on +every following slot, which is needed when something else writes the account in between - a +transaction, or another override fetching it fresh. Persist inputs nothing in the scenario writes +(an oracle price, a disabled switch, a risk parameter), never state the transactions under test +mutate: re-applying reverts their writes at the start of the next slot, so a pool would refill +itself after every swap. Only one entry is queued per override, so it is never applied twice to +one slot, and `fetchBeforeUse` applies to the first slot only - once the account is forked, later +slots re-pin the fields without re-fetching it. + +### Kamino integration tests + +Byte-level Kamino coverage lives in `crates/core/src/tests/kamino/`. Those tests fetch the real +accounts from mainnet, so they need a network connection and are compiled only behind a feature: + +``` +cargo test -p surfpool-core --features integration-tests kamino +``` + +Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint instead of the public one. The default test +run needs no network. + ### Override Templates Directly using the `surfnet_registerScenario` endpoint requires building out a map of account keys that are specific to the schema of the account that is being written to. This is a cumbersome process in most cases. diff --git a/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json b/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json index 72d5c2888..c511bdd4f 100644 --- a/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json +++ b/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json @@ -1,30 +1,44 @@ { "id": "kamino-liquidation-arb-example", - "name": "Kamino Liquidation Arbitrage - POPCAT/SOL", - "description": "A scenario replicating the liquidation arbitrage from tx 5xDtqZcY4CzDHjdT61VsGuF1YL7fADUhPz6hCdA2RVMFMhUjuSh5rqkrLKFXfh4gXevMN1L2NjnCaRCAZYxVmqpz. This scenario sets up a Kamino obligation to be liquidatable, and manipulates Whirlpool and Raydium AMM pool states to create a profitable arbitrage opportunity.", - "tags": ["liquidation", "arbitrage", "kamino", "whirlpool", "raydium"], + "name": "Kamino Liquidation Arbitrage - POPCAT/USDC", + "description": "Puts a live Kamino obligation on the Altcoins Market underwater and leaves a profitable exit. Halving POPCAT in Scope makes Kamino value the collateral below its USDC debt while the Whirlpool pools keep their real price, so a liquidator seizes POPCAT cheaply and sells it POPCAT -> SOL -> USDC. Obligation: 3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS", + "tags": ["liquidation", "arbitrage", "kamino", "scope", "whirlpool"], "overrides": [ { - "id": "obligation-unhealthy", - "templateId": "kamino-obligation-health", - "label": "Make Obligation Unhealthy", + "id": "scope-crash-popcat", + "templateId": "kamino-scope-price", + "label": "POPCAT crashes 50% in Scope (index 492)", "scenarioRelativeSlot": 0, "enabled": true, "fetchBeforeUse": true, + "persist": true, "account": { - "pubkey": "3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS" + "pubkey": "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C" }, "values": { - "borrowed_value_sf": 1000000000000000000, - "unhealthy_borrow_value_sf": 500000000000000000, - "deposited_value_sf": 800000000000000000, - "allowed_borrow_value_sf": 600000000000000000 + "prices.492.price.value": 2124828, + "prices.492.price.exp": 8 } }, { - "id": "whirlpool-popcat-sol-price", + "id": "popcat-reserve-tighten-threshold", + "templateId": "kamino-reserve-config", + "label": "POPCAT reserve liquidates above 29% LTV (was 40%)", + "scenarioRelativeSlot": 0, + "enabled": true, + "fetchBeforeUse": true, + "account": { + "pubkey": "3xSpNvuHAfyzpWxUg2kJkVHWhrBcGLL7RxkZyzecQZkw" + }, + "values": { + "config.liquidation_threshold_pct": 29, + "config.max_liquidation_bonus_bps": 1000 + } + }, + { + "id": "whirlpool-popcat-sol-depth", "templateId": "whirlpool-popcat-sol", - "label": "Set Whirlpool POPCAT/SOL Price", + "label": "Deepen POPCAT/SOL so the exit does not slip", "scenarioRelativeSlot": 0, "enabled": true, "fetchBeforeUse": true, @@ -32,27 +46,21 @@ "pubkey": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE" }, "values": { - "liquidity": "5000000000000000", - "sqrt_price": "1844674407370955161", - "tick_current_index": 0 + "liquidity": 5000000000000000 } }, { - "id": "raydium-amm-popcat-sol-state", - "templateId": "raydium-amm-popcat-sol", - "label": "Set Raydium AMM POPCAT/SOL State", + "id": "whirlpool-sol-usdc-depth", + "templateId": "whirlpool-sol-usdc", + "label": "Deepen SOL/USDC to close the route back to USDC", "scenarioRelativeSlot": 0, "enabled": true, "fetchBeforeUse": true, "account": { - "pubkey": "FRhB8L7Y9Qq41qZXYLtC2nw8An1RJfLLxRF2x9RwLLMo" + "pubkey": "HJPjoWUrhoZzkNfRpHuieeFk9WcZWjwy6PBjZ81ngndJ" }, "values": { - "status": 1, - "state": 1, - "lp_amount": 10000000000000, - "fees.swap_fee_numerator": 25, - "fees.swap_fee_denominator": 10000 + "liquidity": 50000000000000000 } } ] diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md new file mode 100644 index 000000000..7aee1b108 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -0,0 +1,275 @@ +# Kamino + +Surfpool bundles IDLs and override templates for **six Kamino programs**, so a scenario can put a +Kamino market into whatever state you need before your code runs against it. + +This is a how-to. For how scenarios work in general see the [scenarios README](../../README.md) +every field's own purpose and units are on the template itself, visible in Studio and via +`get_override_templates`. + +## Two rules that decide whether an override sticks + +**1. Override inputs, not results.** Kamino stores settings someone chose (`liquidation_threshold_pct`) +and values it computed from them (`market_price_sf`, the Obligation's `*_value_sf`). Before a +liquidation it runs `refresh_reserve` and `refresh_obligation`, which recompute every computed value. +So overriding a computed value is discarded moments later. + +| Want to change | Override this | Not this | +|---|---|---| +| A price | `kamino-scope-price` | `liquidity.market_price_sf` | +| Position health | `kamino-reserve-config` → `liquidation_threshold_pct` | `kamino-obligation-health` | + +**2. Add `"persist": true`** only to inputs your scenario never writes - prices, risk config, +caps. Never to state your transactions mutate (reserve liquidity, obligation or vault balances): +re-applying reverts their writes each slot, so a swap leaves no trace and the arbitrage it measures +is not real. + +## Number formats + +| You'll see | It means | Example | +|---|---|---| +| `_sf` | value x 2^60 | $1.00 → `1152921504606846976` | +| `_bps` | basis points | `100` = 1% | +| `_pct` | whole percent | `74` = 74% | +| Scope `value` / `exp` | `value / 10^exp` | `$0.15` → value `15000000`, exp `8` | +| Farm stake, `reward_per_share_scaled` | value x 2^18 | | +| Token amounts | the mint's smallest unit | 1 USDC → `1000000` | + +## Finding the Scope entry for a token + +Every reserve names its price source. Read the reserve's +`config.token_info.scope_configuration`: + +- `price_feed` - which Scope account to override +- `price_chain` - which entry in it (65535 means unused). If two entries are listed, the price is + the **first multiplied by the second** - that's how a token quoted in SOL is priced. + +Verified 2026-08-11: + +| Scope account | Entries | +|---|---| +| `3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH` | SOL 3, USDC 13, PYUSD 148, cbBTC 175 | +| `3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C` | SOL 0, JLP 416, POPCAT 492 | + +--- + +# Recipes + +## Make a position liquidatable + +Two independent levers where either works, both together is safest. + +```json +{ + "templateId": "kamino-scope-price", + "scenarioRelativeSlot": 0, "enabled": true, + "fetchBeforeUse": true, "persist": true, + "account": { "pubkey": "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C" }, + "values": { "prices.492.price.value": 2124828, "prices.492.price.exp": 8 } +} +``` + +``` +kamino-reserve-config on the collateral reserve + config.liquidation_threshold_pct: 29 # was 40 +``` + +**Why:** halving the collateral's price halves what Kamino thinks it is worth. Lowering the +threshold shrinks the borrow limit. Both survive `refresh_obligation`. See +[`examples/kamino-liquidation-arbitrage.json`](../../examples/kamino-liquidation-arbitrage.json) +for a complete, tested scenario. + +## Turn a liquidation into an arbitrage + +Crash the price in Scope but leave the DEX pools at their real price - the gap between them is the +profit. Add depth so the exit does not slip: + +``` +whirlpool-popcat-sol liquidity: 5000000000000000 # sell the seized collateral +whirlpool-sol-usdc liquidity: 50000000000000000 # route back to the debt token +``` + +## Age a loan instantly + +``` +kamino-reserve-state + liquidity.cumulative_borrow_rate_bsf.value.0: +``` + +**Why:** Kamino derives what a borrower owes from the ratio between this index and the borrower's +snapshot of it. Raising it accrues interest without waiting. + +## Force a reserve to run dry + +``` +kamino-reserve-state liquidity.total_available_amount: 0 +kamino-reserve-limits withdraw_queue.next_withdrawable_ticket_sequence_number: 7 +kamino-lending-market-risk withdraw_ticket_issuance_enabled: 1 +``` + +**Why:** an empty reserve defers withdrawals into a queue. The market-level switch must be on or the +feature never activates. Build the ticket itself with `kamino-withdraw-ticket`. + +## Block an action to test the rejection + +``` +kamino-reserve-limits config.borrow_limit: 0 # no new borrows here +kamino-reserve-status config.status: 1 # reserve obsolete +kamino-lending-market-risk emergency_mode: 1 # market-wide wind-down +kamino-liquidity-strategy-guards withdraw_blocked: 1 # strategy exit blocked +kamino-swap-global-config flash_take_order_blocked: 1 # no flash fills +``` + +## Build a position from scratch + +``` +kamino-obligation-positions + deposits.0.deposit_reserve: + deposits.0.deposited_amount: 10000000000 + borrows.0.borrow_reserve: + borrows.0.borrowed_amount_sf: + has_debt: 1 +``` + +**Why:** element paths let you set one slot. Supplying a whole array needs all 8 (deposits) or 5 +(borrows) entries complete, padding included. + +## Give a farm user claimable rewards + +Fastest - an already-accrued balance, tests only the claim path: + +``` +kamino-farms-user-rewards rewards_issued_unclaimed.0: 500000000 + last_claim_ts.0: 0 +``` + +Realistic - let the program compute the accrual: + +``` +kamino-farms-reward-accumulator reward_infos.0.reward_per_share_scaled: +``` + +**Why:** claimable is `active_stake_scaled x reward_per_share_scaled - rewards_tally_scaled`. +Raising the farm's side and leaving the user's tally alone creates the gap they can claim. + +## Simulate elapsed time + +Every reward and fee mechanism accrues from a timestamp. Move it into the past and the next +accrual covers a longer period - no clock advancing needed. + +``` +kamino-farms-reward-emissions reward_infos.0.last_issuance_ts +kamino-vault-fees last_fee_charge_timestamp +kamino-vault-rewards reward_info.last_issuance_ts +kamino-liquidity-strategy-rewards kamino_rewards.0.last_issuance_ts +``` + +## Make an Earn vault look profitable, or fail + +``` +# earned yield: assets up, shares unchanged +kamino-vault-state token_available: 1000000000 + +# clean share-price assertion: no fees +kamino-vault-fees performance_fee_bps: 0 + management_fee_bps: 0 + +# withdrawal failure: all weight in one reserve, then starve it +kamino-vault-allocation vault_allocation_strategy.0.target_allocation_weight: 100 +kamino-reserve-state liquidity.total_available_amount: 0 +``` + +## Partially fill a limit order + +``` +kamino-swap-order + initial_input_amount: 1000000000 + remaining_input_amount: 500000000 # half filled + expected_output_amount: 100000000 # cheap for the taker + tip_amount: +``` + +--- + +# Troubleshooting + +| Rejection | Fix | +|---|---| +| Price rejected as stale | Set `prices.N.last_updated_slot` / `unix_timestamp` to now, or raise `config.token_info.max_age_price_seconds` on `kamino-reserve-oracle` | +| Price rejected for TWAP divergence | Move the matching entry with `kamino-scope-twap`, or raise `max_twap_divergence_bps` | +| Your override silently did nothing | The field name does not exist in the IDL - surfpool logs a `warn!` and drops the whole override. Check the log | +| `exceeds what a JSON number can hold exactly` | Pass large `u128`/`i128` values as decimal strings, e.g. `"1152921504606846976000"`. Plain JSON numbers are fine below 2^53 | +| `Account with discriminator ... not found in IDL` | The account is not Anchor-based (e.g. Raydium AMM v4). It cannot be overridden through the IDL path | +| `Failed to resolve account address` | The `pubkey` is not valid base58 | +| Override reverted after a transaction touched the account | Add `"persist": true` - but only if that field is an input, not state the transaction is meant to change | +| A value the program recomputes will not stay put | Pin the input it reads instead: Scope price over a Reserve's cached price, `liquidation_threshold_pct` over the Obligation's health fields | + +--- + +# Template index + +**Kamino Lend** · `KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD` + +| Template | Overrides | +|---|---| +| `kamino-reserve-state` | Kamino Reserve liquidity, accrued fees and cached price | +| `kamino-reserve-config` | Kamino Reserve LTV, liquidation thresholds and bonuses | +| `kamino-reserve-status` | Kamino Reserve status and usage restrictions | +| `kamino-reserve-limits` | Kamino Reserve caps and the withdrawal queue | +| `kamino-reserve-fees` | Kamino Reserve origination, flash-loan and protocol fees | +| `kamino-reserve-interest-rate` | the Kamino Reserve borrow-rate curve | +| `kamino-reserve-oracle` | which oracle a Kamino Reserve reads, and its staleness guards | +| `kamino-reserve-rewards` | Kamino Reserve reward emissions | +| `kamino-reserve-debt-term` | Kamino Reserve fixed-term debt settings | +| `kamino-withdraw-ticket` | a Kamino queued-withdrawal ticket | +| `kamino-reserve-main-sol` | the SOL reserve of Kamino's Main Market | +| `kamino-reserve-main-usdc` | the USDC reserve of Kamino's Main Market | +| `kamino-obligation-health` | Kamino Obligation health metrics | +| `kamino-obligation-positions` | the deposits and borrows of a Kamino Obligation | +| `kamino-obligation-orders` | Kamino Obligation stop-loss and take-profit orders | +| `kamino-lending-market-risk` | Kamino market-wide switches and liquidation limits | +| `kamino-lending-market-elevation-groups` | Kamino e-mode elevation groups | + +**Scope oracle** · `HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ` + +| Template | Overrides | +|---|---| +| `kamino-scope-price` | a price in Kamino's Scope oracle | +| `kamino-scope-price-source` | where a Scope index reads its price from | +| `kamino-scope-twap` | a Kamino Scope TWAP entry | + +**Farms** · `FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr` + +| Template | Overrides | +|---|---| +| `kamino-farms-reward-emissions` | a Kamino farm's reward schedule and budget | +| `kamino-farms-reward-accumulator` | a Kamino farm's reward accumulator and staked totals | +| `kamino-farms-user-rewards` | one user's farm stake and reward balances | +| `kamino-farms-farm-config` | Kamino farm caps, lockups and cooldowns | +| `kamino-farms-global-config` | the Kamino Farms treasury fee | + +**Swap (LIMO)** · `LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF` + +| Template | Overrides | +|---|---| +| `kamino-swap-order` | a Kamino limit order's amounts and fill progress | +| `kamino-swap-global-config` | Kamino limit order global switches and fees | + +**Earn vaults** · `KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd` + +| Template | Overrides | +|---|---| +| `kamino-vault-state` | Kamino Earn vault balances and deposit limits | +| `kamino-vault-fees` | Kamino Earn vault performance, management and exit fees | +| `kamino-vault-allocation` | how a Kamino Earn vault spreads deposits across reserves | +| `kamino-vault-rewards` | Kamino Earn vault reward emissions | +| `kamino-vault-reserve-whitelist` | a Kamino Earn vault reserve whitelist entry | + +**Liquidity** · `6LtLpnUFNByNXLyCoK9wA2MykKAmQNZKBdY8s47dehDc` + +| Template | Overrides | +|---|---| +| `kamino-liquidity-strategy-balances` | a Kamino Liquidity strategy's holdings and shares | +| `kamino-liquidity-strategy-rewards` | Kamino Liquidity strategy reward balances | +| `kamino-liquidity-strategy-guards` | Kamino Liquidity strategy caps and slippage guards | +| `kamino-liquidity-strategy-fees` | the Kamino Liquidity strategy's cut of fees and rewards | diff --git a/crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json new file mode 100644 index 000000000..94b811930 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/farms/v1/idl.json @@ -0,0 +1,885 @@ +{ + "address": "FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr", + "metadata": { + "name": "farms", + "version": "1.6.5", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "FarmState", + "discriminator": [ + 198, + 102, + 216, + 74, + 63, + 66, + 163, + 190 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + }, + { + "name": "UserState", + "discriminator": [ + 72, + 177, + 85, + 249, + 76, + 167, + 186, + 126 + ] + }, + { + "name": "OraclePrices", + "discriminator": [ + 89, + 128, + 118, + 221, + 6, + 72, + 180, + 146 + ] + } + ], + "types": [ + { + "name": "FarmConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdateRewardRps" + }, + { + "name": "UpdateRewardMinClaimDuration" + }, + { + "name": "WithdrawAuthority" + }, + { + "name": "DepositWarmupPeriod" + }, + { + "name": "WithdrawCooldownPeriod" + }, + { + "name": "RewardType" + }, + { + "name": "RpsDecimals" + }, + { + "name": "LockingMode" + }, + { + "name": "LockingStartTimestamp" + }, + { + "name": "LockingDuration" + }, + { + "name": "LockingEarlyWithdrawalPenaltyBps" + }, + { + "name": "DepositCapAmount" + }, + { + "name": "SlashedAmountSpillAddress" + }, + { + "name": "ScopePricesAccount" + }, + { + "name": "ScopeOraclePriceId" + }, + { + "name": "ScopeOracleMaxAge" + }, + { + "name": "UpdateRewardScheduleCurvePoints" + }, + { + "name": "UpdatePendingFarmAdmin" + }, + { + "name": "UpdateStrategyId" + }, + { + "name": "UpdateDelegatedRpsAdmin" + }, + { + "name": "UpdateVaultId" + }, + { + "name": "UpdateExtraDelegatedAuthority" + }, + { + "name": "UpdateIsRewardUserOnceEnabled" + }, + { + "name": "UpdateDelegatedAuthority" + }, + { + "name": "UpdateIsHarvestingPermissionless" + } + ] + } + }, + { + "name": "GlobalConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "SetPendingGlobalAdmin" + }, + { + "name": "SetTreasuryFeeBps" + } + ] + } + }, + { + "name": "LockingMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "None" + }, + { + "name": "Continuous" + }, + { + "name": "WithExpiry" + } + ] + } + }, + { + "name": "RewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "token", + "type": { + "defined": { + "name": "TokenInfo" + } + } + }, + { + "name": "rewards_vault", + "type": "pubkey" + }, + { + "name": "rewards_available", + "type": "u64" + }, + { + "name": "reward_schedule_curve", + "type": { + "defined": { + "name": "RewardScheduleCurve" + } + } + }, + { + "name": "min_claim_duration_seconds", + "type": "u64" + }, + { + "name": "last_issuance_ts", + "type": "u64" + }, + { + "name": "rewards_issued_unclaimed", + "type": "u64" + }, + { + "name": "rewards_issued_cumulative", + "type": "u64" + }, + { + "name": "reward_per_share_scaled", + "type": "u128" + }, + { + "name": "placeholder0", + "type": "u64" + }, + { + "name": "reward_type", + "type": "u8" + }, + { + "name": "rewards_per_second_decimals", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 6 + ] + } + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 20 + ] + } + } + ] + } + }, + { + "name": "RewardPerTimeUnitPoint", + "type": { + "kind": "struct", + "fields": [ + { + "name": "ts_start", + "type": "u64" + }, + { + "name": "reward_per_time_unit", + "type": "u64" + } + ] + } + }, + { + "name": "RewardScheduleCurve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "points", + "docs": [ + "This is a stepwise function, meaning that each point represents", + "how many rewards are issued per time unit since the beginning", + "of that point until the beginning of the next point.", + "This is not a linear curve, there is no interpolation going on.", + "A curve can be [[t0, 100], [t1, 50], [t2, 0]]", + "meaning that from t0 to t1, 100 rewards are issued per time unit,", + "from t1 to t2, 50 rewards are issued per time unit, and after t2 it stops", + "Another curve, can be [[t0, 100], [u64::max, 0]]", + "meaning that from t0 to u64::max, 100 rewards are issued per time unit" + ], + "type": { + "array": [ + { + "defined": { + "name": "RewardPerTimeUnitPoint" + } + }, + 20 + ] + } + } + ] + } + }, + { + "name": "RewardType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Proportional" + }, + { + "name": "Constant" + } + ] + } + }, + { + "name": "TimeUnit", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Seconds" + }, + { + "name": "Slots" + } + ] + } + }, + { + "name": "TokenInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "decimals", + "type": "u64" + }, + { + "name": "token_program", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 6 + ] + } + } + ] + } + }, + { + "name": "DatedPrice", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "last_updated_slot", + "type": "u64" + }, + { + "name": "unix_timestamp", + "type": "u64" + }, + { + "name": "reserved", + "type": { + "array": [ + "u64", + 2 + ] + } + }, + { + "name": "reserved2", + "type": { + "array": [ + "u16", + 3 + ] + } + }, + { + "name": "index", + "type": "u16" + } + ] + } + }, + { + "name": "Price", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": "u64" + }, + { + "name": "exp", + "type": "u64" + } + ] + } + }, + { + "name": "FarmState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "farm_admin", + "type": "pubkey" + }, + { + "name": "global_config", + "type": "pubkey" + }, + { + "name": "token", + "type": { + "defined": { + "name": "TokenInfo" + } + } + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "RewardInfo" + } + }, + 10 + ] + } + }, + { + "name": "num_reward_tokens", + "type": "u64" + }, + { + "name": "num_users", + "docs": [ + "Data used to calculate the rewards of the user" + ], + "type": "u64" + }, + { + "name": "total_staked_amount", + "docs": [ + "The number of token in the `farm_vault` staked (getting rewards and fees)", + "Set such as `farm_vault.amount = total_staked_amount + total_pending_amount`" + ], + "type": "u64" + }, + { + "name": "farm_vault", + "type": "pubkey" + }, + { + "name": "farm_vaults_authority", + "type": "pubkey" + }, + { + "name": "farm_vaults_authority_bump", + "type": "u64" + }, + { + "name": "delegate_authority", + "docs": [ + "Only used for delegate farms", + "Set to `default()` otherwise" + ], + "type": "pubkey" + }, + { + "name": "time_unit", + "docs": [ + "Raw representation of a `TimeUnit`", + "Seconds = 0, Slots = 1" + ], + "type": "u8" + }, + { + "name": "is_farm_frozen", + "docs": [ + "Automatically set to true in case of a full authority withdrawal", + "If true, the farm is frozen and no more deposits are allowed" + ], + "type": "u8" + }, + { + "name": "is_farm_delegated", + "docs": [ + "Indicates if the farm is a delegate farm", + "If true, the farm is a delegate farm and the `delegate_authority` is set*" + ], + "type": "u8" + }, + { + "name": "is_reward_user_once_enabled", + "docs": [ + "If set to 1, indicates that the \"reward user once\" feature is enabled" + ], + "type": "u8" + }, + { + "name": "is_harvesting_permissionless", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 3 + ] + } + }, + { + "name": "withdraw_authority", + "docs": [ + "Withdraw authority for the farm, allowed to lock deposited funds and withdraw them", + "Set to `default()` if unused (only the depositors can withdraw their funds)" + ], + "type": "pubkey" + }, + { + "name": "deposit_warmup_period", + "docs": [ + "Delay between a user deposit and the moment it is considered as staked", + "0 if unused" + ], + "type": "u32" + }, + { + "name": "withdrawal_cooldown_period", + "docs": [ + "Delay between a user unstake and the ability to withdraw his deposit." + ], + "type": "u32" + }, + { + "name": "total_active_stake_scaled", + "docs": [ + "Total active stake of tokens in the farm (scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "total_pending_stake_scaled", + "docs": [ + "Total pending stake of tokens in the farm (scaled from `Decimal` representation).", + "(can be used by `withdraw_authority` but don't get rewards or fees)" + ], + "type": "u128" + }, + { + "name": "total_pending_amount", + "docs": [ + "Total pending amount of tokens in the farm" + ], + "type": "u64" + }, + { + "name": "slashed_amount_current", + "docs": [ + "Slashed amounts from early withdrawal" + ], + "type": "u64" + }, + { + "name": "slashed_amount_cumulative", + "type": "u64" + }, + { + "name": "slashed_amount_spill_address", + "type": "pubkey" + }, + { + "name": "locking_mode", + "docs": [ + "Locking stake" + ], + "type": "u64" + }, + { + "name": "locking_start_timestamp", + "type": "u64" + }, + { + "name": "locking_duration", + "type": "u64" + }, + { + "name": "locking_early_withdrawal_penalty_bps", + "type": "u64" + }, + { + "name": "deposit_cap_amount", + "type": "u64" + }, + { + "name": "scope_prices", + "type": "pubkey" + }, + { + "name": "scope_oracle_price_id", + "type": "u64" + }, + { + "name": "scope_oracle_max_age", + "type": "u64" + }, + { + "name": "pending_farm_admin", + "type": "pubkey" + }, + { + "name": "strategy_id", + "type": "pubkey" + }, + { + "name": "delegated_rps_admin", + "type": "pubkey" + }, + { + "name": "vault_id", + "type": "pubkey" + }, + { + "name": "second_delegated_authority", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 74 + ] + } + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "global_admin", + "type": "pubkey" + }, + { + "name": "treasury_fee_bps", + "type": "u64" + }, + { + "name": "treasury_vaults_authority", + "type": "pubkey" + }, + { + "name": "treasury_vaults_authority_bump", + "type": "u64" + }, + { + "name": "pending_global_admin", + "type": "pubkey" + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 126 + ] + } + } + ] + } + }, + { + "name": "UserState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_id", + "type": "u64" + }, + { + "name": "farm_state", + "type": "pubkey" + }, + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "is_farm_delegated", + "docs": [ + "Indicate if this user state is part of a delegated farm" + ], + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "rewards_tally_scaled", + "docs": [ + "Rewards tally used for computation of gained rewards", + "(scaled from `Decimal` representation)." + ], + "type": { + "array": [ + "u128", + 10 + ] + } + }, + { + "name": "rewards_issued_unclaimed", + "docs": [ + "Number of reward tokens ready for claim" + ], + "type": { + "array": [ + "u64", + 10 + ] + } + }, + { + "name": "last_claim_ts", + "type": { + "array": [ + "u64", + 10 + ] + } + }, + { + "name": "active_stake_scaled", + "docs": [ + "User stake deposited and usable, generating rewards and fees.", + "(scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "pending_deposit_stake_scaled", + "docs": [ + "User stake deposited but not usable and not generating rewards yet.", + "(scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "pending_deposit_stake_ts", + "docs": [ + "After this timestamp, pending user stake can be moved to user stake", + "Initialized to now() + delayed user stake period" + ], + "type": "u64" + }, + { + "name": "pending_withdrawal_unstake_scaled", + "docs": [ + "User deposits unstaked, pending for withdrawal, not usable and not generating rewards.", + "(scaled from `Decimal` representation)." + ], + "type": "u128" + }, + { + "name": "pending_withdrawal_unstake_ts", + "docs": [ + "After this timestamp, user can withdraw their deposit." + ], + "type": "u64" + }, + { + "name": "bump", + "docs": [ + "User bump used for account address validation" + ], + "type": "u64" + }, + { + "name": "delegatee", + "docs": [ + "Delegatee used for initialisation - useful to check against" + ], + "type": "pubkey" + }, + { + "name": "last_stake_ts", + "type": "u64" + }, + { + "name": "rewards_issued_cumulative", + "docs": [ + "Cumulative rewards issued to the user - ONLY used for stats/analytics", + "DO NOT USE IN ANY CALCULATIONS", + "Old userStates will have this field populated only from the point of release", + "not reflecting any historical data before this was released" + ], + "type": { + "array": [ + "u64", + 10 + ] + } + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 40 + ] + } + } + ] + } + }, + { + "name": "OraclePrices", + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "prices", + "type": { + "array": [ + { + "defined": { + "name": "DatedPrice" + } + }, + 512 + ] + } + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml new file mode 100644 index 000000000..17d89175e --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml @@ -0,0 +1,219 @@ +protocol: kamino-farms +version: v1.6.5 +account_type: FarmState +idl_file_path: idl.json + +tags: + - rewards + - staking + - farming + - lending + - defi + +templates: + - id: kamino-farms-reward-emissions + name: Override Farm Reward Emissions + description: Override a Kamino farm's reward schedule and budget + idl_account_name: FarmState + properties: + - path: reward_infos.0.token.mint + label: Reward token mint + description: "The token this reward slot pays out. Example: USDC's mint" + - path: reward_infos.0.rewards_vault + label: Reward vault + description: >- + Token account the farm pays rewards out of. Example: any token account for the reward mint + - path: reward_infos.0.rewards_available + label: Rewards remaining + description: "Reward budget still available, in the reward token's smallest unit. Example: 1000000000" + - path: reward_infos.0.reward_schedule_curve + label: Emission schedule + description: "Emission rate over time: 20 {ts_start, reward_per_time_unit} points. Example: 1000" + - path: reward_infos.0.rewards_per_second_decimals + label: Rate decimals + description: "Decimal places applied to the emission rate, allowing sub-unit precision. Example: 6" + - path: reward_infos.0.min_claim_duration_seconds + label: Min claim interval + description: "Seconds a user must wait between harvests. Example: 0" + - path: reward_infos.0.last_issuance_ts + label: Last issuance time + description: "When rewards were last accrued (unix seconds). Example: 1780000000" + - path: reward_infos.0.reward_type + label: Reward type + description: How the emission rate is applied; unlabelled in the IDL, keep as found + - path: num_reward_tokens + label: Active reward slots + description: "How many of the 10 reward slots are in use. Example: 1" + address: + type: pubkey + llm_context: | + A farm has 10 reward slots, reward_infos.0 through reward_infos.9, one per reward token. + + HOW TO USE THIS TEMPLATE: + 1. Replace the 0 in the property paths with the slot you are filling + 2. Raise num_reward_tokens to cover it, or the program does not iterate that slot + 3. Set reward_infos.N.rewards_available, or emissions stop when the budget empties + 4. Set the rate through reward_schedule_curve - EXACTLY 20 {ts_start, reward_per_time_unit} + entries sorted ascending; a flat rate is one point at ts_start 0 + 5. Whether the rate is per second, slot or day comes from time_unit on kamino-farms-farm-config + + EXAMPLE - "flat emission from the beginning of time": + reward_infos.0.reward_schedule_curve.points.0.ts_start: 0 + reward_infos.0.reward_schedule_curve.points.0.reward_per_time_unit: 1000 + reward_infos.0.rewards_available: 1000000000 + num_reward_tokens: 1 + + - id: kamino-farms-reward-accumulator + name: Override Farm Reward Accumulator + description: Override a Kamino farm's reward accumulator and staked totals + idl_account_name: FarmState + properties: + - path: reward_infos.0.reward_per_share_scaled + label: Reward per share + description: "Rewards paid per unit of stake since the farm began (scaled x2^18). Example: 5000000" + - path: reward_infos.0.rewards_issued_unclaimed + label: Unclaimed pool + description: >- + Rewards issued by the farm but not yet harvested by users, in the reward token's smallest + unit. Example: 500000 + - "total_active_stake_scaled" + - "total_pending_stake_scaled" + - "total_staked_amount" + address: + type: pubkey + llm_context: | + Claimable rewards = active_stake_scaled * reward_per_share_scaled - rewards_tally_scaled, + where the tally is the user's marker from their last claim (kamino-farms-user-rewards). + + HOW TO USE THIS TEMPLATE: + 1. To hand a user a reward without simulating time, RAISE + reward_infos.N.reward_per_share_scaled here and LEAVE their tally alone + 2. To test dilution, halve total_active_stake_scaled while holding the accumulator constant + 3. Keep total_staked_amount consistent with the scaled totals, or reward maths and withdrawal + accounting disagree + + Stake and reward_per_share_scaled are scaled by 2^18. + + EXAMPLE - "every staker is owed more": + reward_infos.0.reward_per_share_scaled: 5000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-farms-user-rewards + name: Override Farm User Rewards + description: Override one user's farm stake and reward balances + idl_account_name: UserState + properties: + - path: farm_state + label: Farm + description: "The farm this user position belongs to. Example: the farm's address" + - path: owner + label: Owner + description: "Wallet that owns this staked position and may harvest it. Example: your test wallet" + - "delegatee" + - "is_farm_delegated" + - "active_stake_scaled" + - "pending_deposit_stake_scaled" + - "pending_deposit_stake_ts" + - "pending_withdrawal_unstake_scaled" + - "pending_withdrawal_unstake_ts" + - "rewards_tally_scaled" + - "rewards_issued_unclaimed" + - path: last_claim_ts + label: Last claim per reward + description: "Per-reward-slot timestamp of the last harvest (unix seconds). Example: 0" + - path: last_stake_ts + label: Last stake time + description: "When this user last staked (unix seconds). Example: 1780000000" + address: + type: pubkey + llm_context: | + The per-user half of reward distribution. Each array has 10 slots, one per reward token, + matching reward_infos on the FarmState. + + TWO WAYS TO GIVE A USER REWARDS: + 1. SIMPLEST - set rewards_issued_unclaimed.0 directly. An already-accrued balance, so this + tests only the claim path + 2. REALISTIC - lower rewards_tally_scaled.0 (or raise the farm's reward_per_share_scaled) and + let the program compute the accrual + + Set last_claim_ts.0 far in the past to get past min_claim_duration_seconds on the farm. + Raising active_stake_scaled without raising total_active_stake_scaled on the FarmState makes + the farm over-distribute - useful for insolvency tests, not a realistic starting state. + + EXAMPLE - "user has 500 tokens waiting to be harvested": + rewards_issued_unclaimed.0: 500000000 + last_claim_ts.0: 0 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-farms-farm-config + name: Override Farm Configuration + description: Override Kamino farm caps, lockups and cooldowns + idl_account_name: FarmState + properties: + - "is_farm_frozen" + - "is_farm_delegated" + - path: is_harvesting_permissionless + label: Permissionless harvest + description: >- + 1 lets anyone trigger a harvest on a user's behalf, 0 restricts it to the owner. Example: 1 + - path: deposit_cap_amount + label: Deposit cap + description: "Maximum total stake, in the staked token's smallest unit. Example: 0" + - "deposit_warmup_period" + - "withdrawal_cooldown_period" + - "locking_mode" + - path: locking_start_timestamp + label: Lockup start + description: "When the lockup window opens (unix seconds). Example: 1780000000" + - path: locking_duration + label: Lockup duration + description: "How long stake stays locked, in seconds. Example: 604800" + - path: locking_early_withdrawal_penalty_bps + label: Early exit penalty + description: "Haircut applied when unstaking before the lockup ends in bps. Example: 500" + - "time_unit" + - path: scope_prices + label: Scope price account + description: >- + The Scope OraclePrices account used to value the staked token. Example: + 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH + - path: scope_oracle_price_id + label: Scope index + description: "Which Scope entry values the staked token, 0-511. Example: 3" + - path: scope_oracle_max_age + label: Max price age + description: "How old the Scope price may be before the farm rejects it, in seconds. Example: 600" + address: + type: pubkey + llm_context: | + CRITICAL: time_unit rescales EVERY reward rate on the farm, so change it deliberately. + 0 = seconds, 1 = slots, 2 = days. + + HOW TO USE THIS TEMPLATE: + 1. Zero deposit_warmup_period and withdrawal_cooldown_period so a stake or unstake settles in + the same scenario + 2. Set is_farm_frozen: 1 to block stake and unstake while still allowing harvests + 3. scope_prices and scope_oracle_price_id point at a Scope entry - use the kamino-scope + templates to move that price + + EXAMPLE - "no waiting periods": + deposit_warmup_period: 0 + withdrawal_cooldown_period: 0 + + - id: kamino-farms-global-config + name: Override Farms Global Config + description: Override the Kamino Farms treasury fee + idl_account_name: GlobalConfig + # Do not add the admin pubkeys here. Surfpool runs with signature verification disabled, + # so a scenario can already sign as the real admin without changing who it is. + properties: + - path: treasury_fee_bps + label: Treasury fee + description: >- + The protocol's cut of all rewards in bps, taken before users receive anything. Example: 0 + address: + type: pubkey \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json new file mode 100644 index 000000000..443d6c5ed --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/idl.json @@ -0,0 +1,3276 @@ +{ + "address": "6LtLpnUFNByNXLyCoK9wA2MykKAmQNZKBdY8s47dehDc", + "metadata": { + "name": "yvaults", + "version": "0.1.0", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Whirlpool", + "discriminator": [ + 63, + 149, + 209, + 12, + 225, + 128, + 99, + 9 + ] + }, + { + "name": "Position", + "discriminator": [ + 170, + 188, + 143, + 228, + 122, + 64, + 247, + 208 + ] + }, + { + "name": "PoolState", + "discriminator": [ + 247, + 237, + 227, + 245, + 215, + 195, + 222, + 70 + ] + }, + { + "name": "PersonalPositionState", + "discriminator": [ + 70, + 111, + 150, + 126, + 230, + 15, + 25, + 117 + ] + }, + { + "name": "ProtocolPositionState", + "discriminator": [ + 100, + 226, + 145, + 99, + 146, + 218, + 160, + 106 + ] + }, + { + "name": "WhirlpoolStrategy", + "discriminator": [ + 190, + 178, + 231, + 184, + 49, + 186, + 103, + 13 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + }, + { + "name": "CollateralInfos", + "discriminator": [ + 127, + 210, + 52, + 226, + 74, + 169, + 111, + 9 + ] + }, + { + "name": "ScopeChainAccount", + "discriminator": [ + 180, + 51, + 138, + 247, + 240, + 173, + 119, + 79 + ] + }, + { + "name": "TermsSignature", + "discriminator": [ + 197, + 173, + 136, + 91, + 182, + 49, + 113, + 19 + ] + } + ], + "types": [ + { + "name": "PositionRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "growth_inside_checkpoint", + "type": "u128" + }, + { + "name": "amount_owed", + "type": "u64" + } + ] + } + }, + { + "name": "WhirlpoolRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "docs": [ + "Reward token mint." + ], + "type": "pubkey" + }, + { + "name": "vault", + "docs": [ + "Reward vault token account." + ], + "type": "pubkey" + }, + { + "name": "authority", + "docs": [ + "Authority account that has permission to initialize the reward and set emissions." + ], + "type": "pubkey" + }, + { + "name": "emissions_per_second_x64", + "docs": [ + "Q64.64 number that indicates how many tokens per second are earned per unit of liquidity." + ], + "type": "u128" + }, + { + "name": "growth_global_x64", + "docs": [ + "Q64.64 number that tracks the total tokens earned per unit of liquidity since the reward", + "emissions were turned on." + ], + "type": "u128" + } + ] + } + }, + { + "name": "RewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "reward_state", + "docs": [ + "Reward state" + ], + "type": "u8" + }, + { + "name": "open_time", + "docs": [ + "Reward open time" + ], + "type": "u64" + }, + { + "name": "end_time", + "docs": [ + "Reward end time" + ], + "type": "u64" + }, + { + "name": "last_update_time", + "docs": [ + "Reward last update time" + ], + "type": "u64" + }, + { + "name": "emissions_per_second_x64", + "docs": [ + "Q64.64 number indicates how many tokens per second are earned per unit of liquidity." + ], + "type": "u128" + }, + { + "name": "reward_total_emissioned", + "docs": [ + "The total amount of reward emissioned" + ], + "type": "u64" + }, + { + "name": "reward_claimed", + "docs": [ + "The total amount of claimed reward" + ], + "type": "u64" + }, + { + "name": "token_mint", + "docs": [ + "Reward token mint." + ], + "type": "pubkey" + }, + { + "name": "token_vault", + "docs": [ + "Reward vault token account." + ], + "type": "pubkey" + }, + { + "name": "authority", + "docs": [ + "The owner that has permission to set reward param" + ], + "type": "pubkey" + }, + { + "name": "reward_growth_global_x64", + "docs": [ + "Q64.64 number that tracks the total tokens earned per unit of liquidity since the reward", + "emissions were turned on." + ], + "type": "u128" + } + ] + } + }, + { + "name": "RebalanceRaw", + "type": { + "kind": "struct", + "fields": [ + { + "name": "params", + "type": { + "array": [ + "u8", + 128 + ] + } + }, + { + "name": "state", + "type": { + "array": [ + "u8", + 256 + ] + } + }, + { + "name": "reference_price_type", + "type": "u8" + } + ] + } + }, + { + "name": "CollateralInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "lower_heuristic", + "type": "u64" + }, + { + "name": "upper_heuristic", + "type": "u64" + }, + { + "name": "exp_heuristic", + "type": "u64" + }, + { + "name": "max_twap_divergence_bps", + "type": "u64" + }, + { + "name": "scope_twap_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "name", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "max_age_price_seconds", + "type": "u64" + }, + { + "name": "max_age_twap_seconds", + "type": "u64" + }, + { + "name": "max_ignorable_amount_as_reward", + "type": "u64" + }, + { + "name": "disabled", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "scope_staking_rate_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_feed", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 4 + ] + } + } + ] + } + }, + { + "name": "CollateralInfoParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "lower_heuristic", + "type": "u64" + }, + { + "name": "upper_heuristic", + "type": "u64" + }, + { + "name": "exp_heuristic", + "type": "u64" + }, + { + "name": "max_twap_divergence_bps", + "type": "u64" + }, + { + "name": "scope_twap_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_price_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "name", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "max_age_price_seconds", + "type": "u64" + }, + { + "name": "max_age_twap_seconds", + "type": "u64" + }, + { + "name": "max_ignorable_amount_as_reward", + "type": "u64" + }, + { + "name": "disabled", + "type": "u8" + }, + { + "name": "scope_staking_rate_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "scope_feed", + "type": "pubkey" + } + ] + } + }, + { + "name": "KaminoRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "decimals", + "type": "u64" + }, + { + "name": "reward_vault", + "type": "pubkey" + }, + { + "name": "reward_mint", + "type": "pubkey" + }, + { + "name": "reward_collateral_id", + "type": "u64" + }, + { + "name": "last_issuance_ts", + "type": "u64" + }, + { + "name": "reward_per_second", + "type": "u64" + }, + { + "name": "amount_uncollected", + "type": "u64" + }, + { + "name": "amount_issued_cumulative", + "type": "u64" + }, + { + "name": "amount_available", + "type": "u64" + } + ] + } + }, + { + "name": "WithdrawalCaps", + "type": { + "kind": "struct", + "fields": [ + { + "name": "config_capacity", + "type": "i64" + }, + { + "name": "current_total", + "type": "i64" + }, + { + "name": "last_interval_start_timestamp", + "type": "u64" + }, + { + "name": "config_interval_length_seconds", + "type": "u64" + } + ] + } + }, + { + "name": "Price", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": "u64" + }, + { + "name": "exp", + "type": "u64" + } + ] + } + }, + { + "name": "RebalanceAutodriftParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init_drift_ticks_per_epoch", + "type": "u32" + }, + { + "name": "ticks_below_mid", + "type": "i32" + }, + { + "name": "ticks_above_mid", + "type": "i32" + }, + { + "name": "frontrun_multiplier_bps", + "type": "u16" + }, + { + "name": "staking_rate_a_source", + "type": { + "defined": { + "name": "StakingRateSource" + } + } + }, + { + "name": "staking_rate_b_source", + "type": { + "defined": { + "name": "StakingRateSource" + } + } + }, + { + "name": "init_drift_direction", + "type": { + "defined": { + "name": "DriftDirection" + } + } + } + ] + } + }, + { + "name": "RebalanceAutodriftWindow", + "type": { + "kind": "struct", + "fields": [ + { + "name": "staking_rate_a", + "type": { + "option": { + "defined": { + "name": "Price" + } + } + } + }, + { + "name": "staking_rate_b", + "type": { + "option": { + "defined": { + "name": "Price" + } + } + } + }, + { + "name": "epoch", + "type": "u64" + }, + { + "name": "theoretical_tick", + "type": "i32" + }, + { + "name": "strat_mid_tick", + "type": "i32" + } + ] + } + }, + { + "name": "RebalanceAutodriftState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_window", + "type": { + "defined": { + "name": "RebalanceAutodriftWindow" + } + } + }, + { + "name": "current_window", + "type": { + "defined": { + "name": "RebalanceAutodriftWindow" + } + } + }, + { + "name": "step", + "type": { + "defined": { + "name": "RebalanceAutodriftStep" + } + } + } + ] + } + }, + { + "name": "RebalanceDriftParams", + "type": { + "kind": "struct", + "fields": [ + { + "name": "start_mid_tick", + "type": "i32" + }, + { + "name": "ticks_below_mid", + "type": "i32" + }, + { + "name": "ticks_above_mid", + "type": "i32" + }, + { + "name": "seconds_per_tick", + "type": "u64" + }, + { + "name": "direction", + "type": { + "defined": { + "name": "DriftDirection" + } + } + } + ] + } + }, + { + "name": "RebalanceDriftState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "step", + "type": { + "defined": { + "name": "RebalanceDriftStep" + } + } + }, + { + "name": "last_drift_timestamp", + "type": "u64" + }, + { + "name": "last_mid_tick", + "type": "i32" + } + ] + } + }, + { + "name": "RebalanceExpanderState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "initial_pool_price", + "type": "u128" + }, + { + "name": "expansion_count", + "type": "u16" + } + ] + } + }, + { + "name": "RebalanceManualState", + "type": { + "kind": "struct" + } + }, + { + "name": "PeriodicRebalanceState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_rebalance_timestamp", + "type": "u64" + } + ] + } + }, + { + "name": "RebalancePricePercentageWithResetState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_rebalance_lower_reset_pool_price", + "type": "u128" + }, + { + "name": "last_rebalance_upper_reset_pool_price", + "type": "u128" + } + ] + } + }, + { + "name": "RebalancePricePercentageState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_rebalance_lower_pool_price", + "type": "u128" + }, + { + "name": "last_rebalance_upper_pool_price", + "type": "u128" + } + ] + } + }, + { + "name": "RebalanceTakeProfitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "step", + "type": { + "defined": { + "name": "RebalanceTakeProfitStep" + } + } + } + ] + } + }, + { + "name": "BinAddLiquidityStrategy", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uniform", + "fields": [ + { + "name": "current_bin_index", + "type": "i32" + }, + { + "name": "lower_bin_index", + "type": "i32" + }, + { + "name": "upper_bin_index", + "type": "i32" + }, + { + "name": "amount_x_to_deposit", + "type": "u64" + }, + { + "name": "amount_y_to_deposit", + "type": "u64" + }, + { + "name": "x_current_bin", + "type": "u64" + }, + { + "name": "y_current_bin", + "type": "u64" + } + ] + }, + { + "name": "CurrentTick", + "fields": [ + "i32" + ] + } + ] + } + }, + { + "name": "SimulationPrice", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PoolPrice" + }, + { + "name": "SqrtPrice", + "fields": [ + "u128" + ] + }, + { + "name": "TickIndex", + "fields": [ + "i32" + ] + } + ] + } + }, + { + "name": "DexSpecificPrice", + "type": { + "kind": "enum", + "variants": [ + { + "name": "SqrtPrice", + "fields": [ + "u128" + ] + }, + { + "name": "Q64_64", + "fields": [ + "u128" + ] + } + ] + } + }, + { + "name": "RemoveLiquidityMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Liquidity", + "fields": [ + "u128" + ] + }, + { + "name": "Bps", + "fields": [ + "u16" + ] + }, + { + "name": "All" + } + ] + } + }, + { + "name": "WithdrawalCapAccumulatorAction", + "type": { + "kind": "enum", + "variants": [ + { + "name": "KeepAccumulator" + }, + { + "name": "ResetAccumulator" + } + ] + } + }, + { + "name": "RebalanceEffects", + "type": { + "kind": "enum", + "variants": [ + { + "name": "NewRange", + "fields": [ + "i32", + "i32" + ] + }, + { + "name": "WithdrawAndFreeze" + } + ] + } + }, + { + "name": "SwapLimit", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Bps", + "fields": [ + "u64" + ] + }, + { + "name": "Absolute", + "fields": [ + { + "name": "src_amount_to_swap", + "docs": [ + "Amount of src token expected by the user to perform the swap" + ], + "type": "u64" + }, + { + "name": "dst_amount_to_vault", + "docs": [ + "Amount of dst token the user provides in exchange" + ], + "type": "u64" + }, + { + "name": "a_to_b", + "type": "bool" + } + ] + } + ] + } + }, + { + "name": "MintingMethod", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PriceBased" + }, + { + "name": "Proportional" + } + ] + } + }, + { + "name": "GlobalConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "EmergencyMode" + }, + { + "name": "BlockDeposit" + }, + { + "name": "BlockInvest" + }, + { + "name": "BlockWithdraw" + }, + { + "name": "BlockCollectFees" + }, + { + "name": "BlockCollectRewards" + }, + { + "name": "BlockSwapRewards" + }, + { + "name": "BlockSwapUnevenVaults" + }, + { + "name": "WithdrawalFeeBps" + }, + { + "name": "DeprecatedSwapDiscountBps" + }, + { + "name": "ActionsAuthority" + }, + { + "name": "DeprecatedTreasuryFeeVaults" + }, + { + "name": "AdminAuthority" + }, + { + "name": "BlockEmergencySwap" + }, + { + "name": "BlockLocalAdmin" + }, + { + "name": "UpdateTokenInfos" + }, + { + "name": "ScopeProgramId" + }, + { + "name": "UpdateScopePriceId" + }, + { + "name": "MinPerformanceFeeBps" + }, + { + "name": "MinSwapUnevenSlippageToleranceBps" + }, + { + "name": "MinReferencePriceSlippageToleranceBps" + }, + { + "name": "ActionsAfterRebalanceDelaySeconds" + }, + { + "name": "TreasuryFeeVaultReceiver" + }, + { + "name": "AddScopePriceId" + }, + { + "name": "MaxDeviationFromRefPriceOnInvestBps" + }, + { + "name": "InvestCooldownSlots" + }, + { + "name": "MinInvestTriggerValueUsd" + } + ] + } + }, + { + "name": "StrategyConfigOption", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdateDepositCap" + }, + { + "name": "UpdateDepositCapIxn" + }, + { + "name": "UpdateWithdrawalCapACapacity" + }, + { + "name": "UpdateWithdrawalCapAInterval" + }, + { + "name": "UpdateWithdrawalCapACurrentTotal" + }, + { + "name": "UpdateWithdrawalCapBCapacity" + }, + { + "name": "UpdateWithdrawalCapBInterval" + }, + { + "name": "UpdateWithdrawalCapBCurrentTotal" + }, + { + "name": "UpdateMaxDeviationBps" + }, + { + "name": "UpdateSwapVaultMaxSlippage" + }, + { + "name": "UpdateStrategyType" + }, + { + "name": "UpdateDepositFee" + }, + { + "name": "UpdateWithdrawFee" + }, + { + "name": "UpdateCollectFeesFee" + }, + { + "name": "UpdateReward0Fee" + }, + { + "name": "UpdateReward1Fee" + }, + { + "name": "UpdateReward2Fee" + }, + { + "name": "UpdateAdminAuthority" + }, + { + "name": "KaminoRewardIndex0TS" + }, + { + "name": "KaminoRewardIndex1TS" + }, + { + "name": "KaminoRewardIndex2TS" + }, + { + "name": "KaminoRewardIndex0RewardPerSecond" + }, + { + "name": "KaminoRewardIndex1RewardPerSecond" + }, + { + "name": "KaminoRewardIndex2RewardPerSecond" + }, + { + "name": "UpdateDepositBlocked" + }, + { + "name": "UpdateRaydiumProtocolPositionOrBaseVaultAuthority" + }, + { + "name": "UpdateRaydiumPoolConfigOrBaseVaultAuthority" + }, + { + "name": "UpdateInvestBlocked" + }, + { + "name": "UpdateWithdrawBlocked" + }, + { + "name": "UpdateLocalAdminBlocked" + }, + { + "name": "DeprecatedUpdateCollateralIdA" + }, + { + "name": "DeprecatedUpdateCollateralIdB" + }, + { + "name": "UpdateFlashVaultSwap" + }, + { + "name": "AllowDepositWithoutInvest" + }, + { + "name": "UpdateSwapVaultMaxSlippageFromRef" + }, + { + "name": "ResetReferencePrices" + }, + { + "name": "UpdateStrategyCreationState" + }, + { + "name": "UpdateIsCommunity" + }, + { + "name": "UpdateRebalanceType" + }, + { + "name": "UpdateRebalanceParams" + }, + { + "name": "UpdateDepositMintingMethod" + }, + { + "name": "UpdateLookupTable" + }, + { + "name": "UpdateReferencePriceType" + }, + { + "name": "UpdateReward0Amount" + }, + { + "name": "UpdateReward1Amount" + }, + { + "name": "UpdateReward2Amount" + }, + { + "name": "UpdateFarm" + }, + { + "name": "UpdateRebalancesCapCapacity" + }, + { + "name": "UpdateRebalancesCapInterval" + }, + { + "name": "UpdateRebalancesCapCurrentTotal" + }, + { + "name": "DeprecatedUpdateSwapUnevenAuthority" + }, + { + "name": "UpdatePendingStrategyAdmin" + }, + { + "name": "UpdateMaxDeviationFromRefPriceOnInvestBps" + } + ] + } + }, + { + "name": "StrategyStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "Active" + }, + { + "name": "Frozen" + }, + { + "name": "Rebalancing" + }, + { + "name": "NoPosition" + } + ] + } + }, + { + "name": "StrategyType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Stable" + }, + { + "name": "Pegged" + }, + { + "name": "Volatile" + } + ] + } + }, + { + "name": "CreationStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "IGNORED" + }, + { + "name": "SHADOW" + }, + { + "name": "LIVE" + }, + { + "name": "DEPRECATED" + }, + { + "name": "STAGING" + } + ] + } + }, + { + "name": "ExecutiveWithdrawAction", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Freeze" + }, + { + "name": "Unfreeze" + }, + { + "name": "Rebalance" + } + ] + } + }, + { + "name": "ReferencePriceType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "POOL" + }, + { + "name": "TWAP" + } + ] + } + }, + { + "name": "LiquidityCalculationMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Deposit" + }, + { + "name": "Withdraw" + } + ] + } + }, + { + "name": "UpdateCollateralInfoMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "CollateralId" + }, + { + "name": "LowerHeuristic" + }, + { + "name": "UpperHeuristic" + }, + { + "name": "ExpHeuristic" + }, + { + "name": "TwapDivergence" + }, + { + "name": "UpdateScopeTwap" + }, + { + "name": "UpdateScopeChain" + }, + { + "name": "UpdateName" + }, + { + "name": "UpdatePriceMaxAge" + }, + { + "name": "UpdateTwapMaxAge" + }, + { + "name": "UpdateDisabled" + }, + { + "name": "UpdateStakingRateChain" + }, + { + "name": "UpdateMaxIgnorableAmountAsReward" + }, + { + "name": "UpdateScopeFeed" + } + ] + } + }, + { + "name": "BalanceStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Balanced" + }, + { + "name": "Unbalanced" + } + ] + } + }, + { + "name": "RebalanceAutodriftStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "Autodrifting" + } + ] + } + }, + { + "name": "StakingRateSource", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Constant" + }, + { + "name": "Scope" + } + ] + } + }, + { + "name": "DriftDirection", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Increasing" + }, + { + "name": "Decreasing" + } + ] + } + }, + { + "name": "RebalanceDriftStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "Drifting" + } + ] + } + }, + { + "name": "ExpanderStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "ExpandOrContract", + "fields": [ + "u16" + ] + }, + { + "name": "Recenter" + } + ] + } + }, + { + "name": "RebalanceTakeProfitToken", + "type": { + "kind": "enum", + "variants": [ + { + "name": "A" + }, + { + "name": "B" + } + ] + } + }, + { + "name": "RebalanceTakeProfitStep", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Uninitialized" + }, + { + "name": "TakingProfit" + }, + { + "name": "Finished" + } + ] + } + }, + { + "name": "RebalanceAction", + "type": { + "kind": "enum", + "variants": [ + { + "name": "NewPriceRange", + "fields": [ + { + "defined": { + "name": "DexSpecificPrice" + } + }, + { + "defined": { + "name": "DexSpecificPrice" + } + } + ] + }, + { + "name": "NewTickRange", + "fields": [ + "i32", + "i32" + ] + }, + { + "name": "WithdrawAndFreeze" + } + ] + } + }, + { + "name": "RebalanceType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Manual" + }, + { + "name": "PricePercentage" + }, + { + "name": "PricePercentageWithReset" + }, + { + "name": "Drift" + }, + { + "name": "TakeProfit" + }, + { + "name": "PeriodicRebalance" + }, + { + "name": "Expander" + }, + { + "name": "Autodrift" + } + ] + } + }, + { + "name": "CollateralTestToken", + "type": { + "kind": "enum", + "variants": [ + { + "name": "USDC" + }, + { + "name": "USDH" + }, + { + "name": "SOL" + }, + { + "name": "ETH" + }, + { + "name": "BTC" + }, + { + "name": "MSOL" + }, + { + "name": "STSOL" + }, + { + "name": "USDT" + }, + { + "name": "ORCA" + }, + { + "name": "MNDE" + }, + { + "name": "HBB" + }, + { + "name": "JSOL" + }, + { + "name": "USH" + }, + { + "name": "DAI" + }, + { + "name": "LDO" + }, + { + "name": "SCNSOL" + }, + { + "name": "UXD" + }, + { + "name": "HDG" + }, + { + "name": "DUST" + }, + { + "name": "USDR" + }, + { + "name": "RATIO" + }, + { + "name": "UXP" + }, + { + "name": "JITOSOL" + }, + { + "name": "RAY" + }, + { + "name": "BONK" + }, + { + "name": "SAMO" + }, + { + "name": "LaineSOL" + }, + { + "name": "BSOL" + } + ] + } + }, + { + "name": "ScopePriceIdTest", + "type": { + "kind": "enum", + "variants": [ + { + "name": "SOL" + }, + { + "name": "ETH" + }, + { + "name": "BTC" + }, + { + "name": "SRM" + }, + { + "name": "RAY" + }, + { + "name": "FTT" + }, + { + "name": "MSOL" + }, + { + "name": "scnSOL_SOL" + }, + { + "name": "BNB" + }, + { + "name": "AVAX" + }, + { + "name": "DaoSOL_SOL" + }, + { + "name": "SaberMSOL_SOL" + }, + { + "name": "USDH" + }, + { + "name": "StSOL" + }, + { + "name": "CSOL_SOL" + }, + { + "name": "CETH_ETH" + }, + { + "name": "CBTC_BTC" + }, + { + "name": "CMSOL_SOL" + }, + { + "name": "wstETH" + }, + { + "name": "LDO" + }, + { + "name": "USDC" + }, + { + "name": "CUSDC_USDC" + }, + { + "name": "USDT" + }, + { + "name": "ORCA" + }, + { + "name": "MNDE" + }, + { + "name": "HBB" + }, + { + "name": "CORCA_ORCA" + }, + { + "name": "CSLND_SLND" + }, + { + "name": "CSRM_SRM" + }, + { + "name": "CRAY_RAY" + }, + { + "name": "CFTT_FTT" + }, + { + "name": "CSTSOL_STSOL" + }, + { + "name": "SLND" + }, + { + "name": "DAI" + }, + { + "name": "JSOL_SOL" + }, + { + "name": "USH" + }, + { + "name": "UXD" + }, + { + "name": "USDH_TWAP" + }, + { + "name": "USH_TWAP" + }, + { + "name": "UXD_TWAP" + }, + { + "name": "HDG" + }, + { + "name": "DUST" + }, + { + "name": "USDR" + }, + { + "name": "USDR_TWAP" + }, + { + "name": "RATIO" + }, + { + "name": "UXP" + }, + { + "name": "KUXDUSDCORCA" + }, + { + "name": "JITOSOL_SOL" + }, + { + "name": "SOL_EMA" + }, + { + "name": "ETH_EMA" + }, + { + "name": "BTC_EMA" + }, + { + "name": "SRM_EMA" + }, + { + "name": "RAY_EMA" + }, + { + "name": "FTT_EMA" + }, + { + "name": "MSOL_EMA" + }, + { + "name": "BNB_EMA" + }, + { + "name": "AVAX_EMA" + }, + { + "name": "STSOL_EMA" + }, + { + "name": "USDC_EMA" + }, + { + "name": "USDT_EMA" + }, + { + "name": "SLND_EMA" + }, + { + "name": "DAI_EMA" + }, + { + "name": "wstETH_TWAP" + }, + { + "name": "DUST_TWAP" + }, + { + "name": "BONK" + }, + { + "name": "BONK_TWAP" + }, + { + "name": "SAMO" + }, + { + "name": "SAMO_TWAP" + }, + { + "name": "BSOL" + }, + { + "name": "LaineSOL" + } + ] + } + }, + { + "name": "DEX", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Orca" + }, + { + "name": "Raydium" + }, + { + "name": "Meteora" + } + ] + } + }, + { + "name": "Whirlpool", + "docs": [ + "External types" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpools_config", + "type": "pubkey" + }, + { + "name": "whirlpool_bump", + "type": { + "array": [ + "u8", + 1 + ] + } + }, + { + "name": "tick_spacing", + "type": "u16" + }, + { + "name": "tick_spacing_seed", + "type": { + "array": [ + "u8", + 2 + ] + } + }, + { + "name": "fee_rate", + "type": "u16" + }, + { + "name": "protocol_fee_rate", + "type": "u16" + }, + { + "name": "liquidity", + "type": "u128" + }, + { + "name": "sqrt_price", + "type": "u128" + }, + { + "name": "tick_current_index", + "type": "i32" + }, + { + "name": "protocol_fee_owed_a", + "type": "u64" + }, + { + "name": "protocol_fee_owed_b", + "type": "u64" + }, + { + "name": "token_mint_a", + "type": "pubkey" + }, + { + "name": "token_vault_a", + "type": "pubkey" + }, + { + "name": "fee_growth_global_a", + "type": "u128" + }, + { + "name": "token_mint_b", + "type": "pubkey" + }, + { + "name": "token_vault_b", + "type": "pubkey" + }, + { + "name": "fee_growth_global_b", + "type": "u128" + }, + { + "name": "reward_last_updated_timestamp", + "type": "u64" + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "WhirlpoolRewardInfo" + } + }, + 3 + ] + } + } + ] + } + }, + { + "name": "Position", + "type": { + "kind": "struct", + "fields": [ + { + "name": "whirlpool", + "type": "pubkey" + }, + { + "name": "position_mint", + "type": "pubkey" + }, + { + "name": "liquidity", + "type": "u128" + }, + { + "name": "tick_lower_index", + "type": "i32" + }, + { + "name": "tick_upper_index", + "type": "i32" + }, + { + "name": "fee_growth_checkpoint_a", + "type": "u128" + }, + { + "name": "fee_owed_a", + "type": "u64" + }, + { + "name": "fee_growth_checkpoint_b", + "type": "u128" + }, + { + "name": "fee_owed_b", + "type": "u64" + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "PositionRewardInfo" + } + }, + 3 + ] + } + } + ] + } + }, + { + "name": "PoolState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "Bump to identify PDA" + ], + "type": "u8" + }, + { + "name": "amm_config", + "type": "pubkey" + }, + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "token_mint0", + "docs": [ + "Token pair of the pool, where token_mint_0 address < token_mint_1 address" + ], + "type": "pubkey" + }, + { + "name": "token_mint1", + "type": "pubkey" + }, + { + "name": "token_vault0", + "docs": [ + "Token pair vault" + ], + "type": "pubkey" + }, + { + "name": "token_vault1", + "type": "pubkey" + }, + { + "name": "observation_key", + "docs": [ + "observation account key" + ], + "type": "pubkey" + }, + { + "name": "mint_decimals0", + "docs": [ + "mint0 and mint1 decimals" + ], + "type": "u8" + }, + { + "name": "mint_decimals1", + "type": "u8" + }, + { + "name": "tick_spacing", + "docs": [ + "The minimum number of ticks between initialized ticks" + ], + "type": "u16" + }, + { + "name": "liquidity", + "docs": [ + "The currently in range liquidity available to the pool." + ], + "type": "u128" + }, + { + "name": "sqrt_price_x64", + "docs": [ + "The current price of the pool as a sqrt(token_1/token_0) Q64.64 value" + ], + "type": "u128" + }, + { + "name": "tick_current", + "docs": [ + "The current tick of the pool, i.e. according to the last tick transition that was run." + ], + "type": "i32" + }, + { + "name": "observation_index", + "docs": [ + "the most-recently updated index of the observations array" + ], + "type": "u16" + }, + { + "name": "observation_update_duration", + "type": "u16" + }, + { + "name": "fee_growth_global0_x64", + "docs": [ + "The fee growth as a Q64.64 number, i.e. fees of token_0 and token_1 collected per", + "unit of liquidity for the entire life of the pool." + ], + "type": "u128" + }, + { + "name": "fee_growth_global1_x64", + "type": "u128" + }, + { + "name": "protocol_fees_token0", + "docs": [ + "The amounts of token_0 and token_1 that are owed to the protocol." + ], + "type": "u64" + }, + { + "name": "protocol_fees_token1", + "type": "u64" + }, + { + "name": "swap_in_amount_token0", + "docs": [ + "The amounts in and out of swap token_0 and token_1" + ], + "type": "u128" + }, + { + "name": "swap_out_amount_token1", + "type": "u128" + }, + { + "name": "swap_in_amount_token1", + "type": "u128" + }, + { + "name": "swap_out_amount_token0", + "type": "u128" + }, + { + "name": "status", + "docs": [ + "Bitwise representation of the state of the pool", + "bit0, 1: disable open position and increase liquidity, 0: normal", + "bit1, 1: disable decrease liquidity, 0: normal", + "bit2, 1: disable collect fee, 0: normal", + "bit3, 1: disable collect reward, 0: normal", + "bit4, 1: disable swap, 0: normal" + ], + "type": "u8" + }, + { + "name": "padding", + "docs": [ + "Leave blank for future use" + ], + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "RewardInfo" + } + }, + 3 + ] + } + }, + { + "name": "tick_array_bitmap", + "docs": [ + "Packed initialized tick array state" + ], + "type": { + "array": [ + "u64", + 16 + ] + } + }, + { + "name": "total_fees_token0", + "docs": [ + "except protocol_fee and fund_fee" + ], + "type": "u64" + }, + { + "name": "total_fees_claimed_token0", + "docs": [ + "except protocol_fee and fund_fee" + ], + "type": "u64" + }, + { + "name": "total_fees_token1", + "type": "u64" + }, + { + "name": "total_fees_claimed_token1", + "type": "u64" + }, + { + "name": "fund_fees_token0", + "type": "u64" + }, + { + "name": "fund_fees_token1", + "type": "u64" + }, + { + "name": "open_time", + "type": "u64" + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 25 + ] + } + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 32 + ] + } + } + ] + } + }, + { + "name": "PersonalPositionState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "Bump to identify PDA" + ], + "type": "u8" + }, + { + "name": "nft_mint", + "docs": [ + "Mint address of the tokenized position" + ], + "type": "pubkey" + }, + { + "name": "pool_id", + "docs": [ + "The ID of the pool with which this token is connected" + ], + "type": "pubkey" + }, + { + "name": "tick_lower_index", + "docs": [ + "The lower bound tick of the position" + ], + "type": "i32" + }, + { + "name": "tick_upper_index", + "docs": [ + "The upper bound tick of the position" + ], + "type": "i32" + }, + { + "name": "liquidity", + "docs": [ + "The amount of liquidity owned by this position" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside0_last_x64", + "docs": [ + "The token_0 fee growth of the aggregate position as of the last action on the individual position" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside1_last_x64", + "docs": [ + "The token_1 fee growth of the aggregate position as of the last action on the individual position" + ], + "type": "u128" + }, + { + "name": "token_fees_owed0", + "docs": [ + "The fees owed to the position owner in token_0, as of the last computation" + ], + "type": "u64" + }, + { + "name": "token_fees_owed1", + "docs": [ + "The fees owed to the position owner in token_1, as of the last computation" + ], + "type": "u64" + }, + { + "name": "reward_infos", + "type": { + "array": [ + { + "defined": { + "name": "PositionRewardInfo" + } + }, + 3 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "ProtocolPositionState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "Bump to identify PDA" + ], + "type": "u8" + }, + { + "name": "pool_id", + "docs": [ + "The ID of the pool with which this token is connected" + ], + "type": "pubkey" + }, + { + "name": "tick_lower_index", + "docs": [ + "The lower bound tick of the position" + ], + "type": "i32" + }, + { + "name": "tick_upper_index", + "docs": [ + "The upper bound tick of the position" + ], + "type": "i32" + }, + { + "name": "liquidity", + "docs": [ + "The amount of liquidity owned by this position" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside0_last_x64", + "docs": [ + "The token_0 fee growth per unit of liquidity as of the last update to liquidity or fees owed" + ], + "type": "u128" + }, + { + "name": "fee_growth_inside1_last_x64", + "docs": [ + "The token_1 fee growth per unit of liquidity as of the last update to liquidity or fees owed" + ], + "type": "u128" + }, + { + "name": "token_fees_owed0", + "docs": [ + "The fees owed to the position owner in token_0" + ], + "type": "u64" + }, + { + "name": "token_fees_owed1", + "docs": [ + "The fees owed to the position owner in token_1" + ], + "type": "u64" + }, + { + "name": "reward_growth_inside", + "docs": [ + "The reward growth per unit of liquidity as of the last update to liquidity" + ], + "type": { + "array": [ + "u128", + 3 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "WhirlpoolStrategy", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "global_config", + "type": "pubkey" + }, + { + "name": "base_vault_authority", + "type": "pubkey" + }, + { + "name": "base_vault_authority_bump", + "type": "u64" + }, + { + "name": "pool", + "type": "pubkey" + }, + { + "name": "pool_token_vault_a", + "type": "pubkey" + }, + { + "name": "pool_token_vault_b", + "type": "pubkey" + }, + { + "name": "tick_array_lower", + "type": "pubkey" + }, + { + "name": "tick_array_upper", + "type": "pubkey" + }, + { + "name": "position", + "type": "pubkey" + }, + { + "name": "position_mint", + "type": "pubkey" + }, + { + "name": "position_metadata", + "type": "pubkey" + }, + { + "name": "position_token_account", + "type": "pubkey" + }, + { + "name": "token_a_vault", + "type": "pubkey" + }, + { + "name": "token_b_vault", + "type": "pubkey" + }, + { + "name": "deprecated0", + "type": { + "array": [ + "pubkey", + 2 + ] + } + }, + { + "name": "deprecated1", + "type": { + "array": [ + "u64", + 2 + ] + } + }, + { + "name": "token_a_mint", + "type": "pubkey" + }, + { + "name": "token_b_mint", + "type": "pubkey" + }, + { + "name": "token_a_mint_decimals", + "type": "u64" + }, + { + "name": "token_b_mint_decimals", + "type": "u64" + }, + { + "name": "token_a_amounts", + "type": "u64" + }, + { + "name": "token_b_amounts", + "type": "u64" + }, + { + "name": "token_a_collateral_id", + "type": "u64" + }, + { + "name": "token_b_collateral_id", + "type": "u64" + }, + { + "name": "deprecated2", + "type": "pubkey" + }, + { + "name": "deprecated3", + "type": "pubkey" + }, + { + "name": "shares_mint", + "type": "pubkey" + }, + { + "name": "shares_mint_decimals", + "type": "u64" + }, + { + "name": "shares_mint_authority", + "type": "pubkey" + }, + { + "name": "shares_mint_authority_bump", + "type": "u64" + }, + { + "name": "shares_issued", + "type": "u64" + }, + { + "name": "status", + "type": "u64" + }, + { + "name": "reward0_amount", + "type": "u64" + }, + { + "name": "reward0_vault", + "type": "pubkey" + }, + { + "name": "reward0_collateral_id", + "type": "u64" + }, + { + "name": "reward0_decimals", + "type": "u64" + }, + { + "name": "reward1_amount", + "type": "u64" + }, + { + "name": "reward1_vault", + "type": "pubkey" + }, + { + "name": "reward1_collateral_id", + "type": "u64" + }, + { + "name": "reward1_decimals", + "type": "u64" + }, + { + "name": "reward2_amount", + "type": "u64" + }, + { + "name": "reward2_vault", + "type": "pubkey" + }, + { + "name": "reward2_collateral_id", + "type": "u64" + }, + { + "name": "reward2_decimals", + "type": "u64" + }, + { + "name": "deposit_cap_usd", + "type": "u64" + }, + { + "name": "fees_a_cumulative", + "type": "u64" + }, + { + "name": "fees_b_cumulative", + "type": "u64" + }, + { + "name": "reward0_amount_cumulative", + "type": "u64" + }, + { + "name": "reward1_amount_cumulative", + "type": "u64" + }, + { + "name": "reward2_amount_cumulative", + "type": "u64" + }, + { + "name": "deposit_cap_usd_per_ixn", + "type": "u64" + }, + { + "name": "withdrawal_cap_a", + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "withdrawal_cap_b", + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "max_price_deviation_bps", + "type": "u64" + }, + { + "name": "swap_vault_max_slippage_bps", + "type": "u32" + }, + { + "name": "swap_vault_max_slippage_from_reference_bps", + "type": "u32" + }, + { + "name": "strategy_type", + "type": "u64" + }, + { + "name": "padding0", + "type": "u64" + }, + { + "name": "withdraw_fee", + "type": "u64" + }, + { + "name": "fees_fee", + "type": "u64" + }, + { + "name": "reward0_fee", + "type": "u64" + }, + { + "name": "reward1_fee", + "type": "u64" + }, + { + "name": "reward2_fee", + "type": "u64" + }, + { + "name": "position_timestamp", + "type": "u64" + }, + { + "name": "kamino_rewards", + "type": { + "array": [ + { + "defined": { + "name": "KaminoRewardInfo" + } + }, + 3 + ] + } + }, + { + "name": "strategy_dex", + "type": "u64" + }, + { + "name": "raydium_protocol_position_or_base_vault_authority", + "type": "pubkey" + }, + { + "name": "allow_deposit_without_invest", + "type": "u64" + }, + { + "name": "raydium_pool_config_or_base_vault_authority", + "type": "pubkey" + }, + { + "name": "deposit_blocked", + "type": "u8" + }, + { + "name": "creation_status", + "type": "u8" + }, + { + "name": "invest_blocked", + "type": "u8" + }, + { + "name": "share_calculation_method", + "docs": [ + "share_calculation_method can be either DOLAR_BASED=0 or PROPORTION_BASED=1" + ], + "type": "u8" + }, + { + "name": "withdraw_blocked", + "type": "u8" + }, + { + "name": "reserved_flag2", + "type": "u8" + }, + { + "name": "local_admin_blocked", + "type": "u8" + }, + { + "name": "flash_vault_swap_allowed", + "type": "u8" + }, + { + "name": "reference_swap_price_a", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "reference_swap_price_b", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "is_community", + "type": "u8" + }, + { + "name": "rebalance_type", + "type": "u8" + }, + { + "name": "flash_swap_in_progress", + "type": "u8" + }, + { + "name": "padding1", + "type": { + "array": [ + "u8", + 5 + ] + } + }, + { + "name": "rebalance_raw", + "type": { + "defined": { + "name": "RebalanceRaw" + } + } + }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "token_a_fees_from_rewards_cumulative", + "type": "u64" + }, + { + "name": "token_b_fees_from_rewards_cumulative", + "type": "u64" + }, + { + "name": "strategy_lookup_table", + "type": "pubkey" + }, + { + "name": "last_swap_uneven_step_timestamp", + "type": "u64" + }, + { + "name": "farm", + "type": "pubkey" + }, + { + "name": "rebalances_cap", + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "padding3_non_zeroed", + "type": { + "array": [ + "u64", + 4 + ] + } + }, + { + "name": "token_a_token_program", + "type": "pubkey" + }, + { + "name": "token_b_token_program", + "type": "pubkey" + }, + { + "name": "pending_admin", + "type": "pubkey" + }, + { + "name": "max_deviation_from_ref_price_on_invest_bps", + "type": "u32" + }, + { + "name": "padding3", + "type": "u32" + }, + { + "name": "last_invest_slot", + "type": "u64" + }, + { + "name": "padding4", + "type": "u64" + }, + { + "name": "padding5", + "type": { + "array": [ + "u128", + 12 + ] + } + }, + { + "name": "padding6", + "type": { + "array": [ + "u128", + 32 + ] + } + }, + { + "name": "padding7", + "type": { + "array": [ + "u128", + 32 + ] + } + }, + { + "name": "padding8", + "type": { + "array": [ + "u128", + 32 + ] + } + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "emergency_mode", + "type": "u64" + }, + { + "name": "block_deposit", + "type": "u64" + }, + { + "name": "block_invest", + "type": "u64" + }, + { + "name": "block_withdraw", + "type": "u64" + }, + { + "name": "block_collect_fees", + "type": "u64" + }, + { + "name": "block_collect_rewards", + "type": "u64" + }, + { + "name": "block_swap_rewards", + "type": "u64" + }, + { + "name": "block_swap_uneven_vaults", + "type": "u32" + }, + { + "name": "block_emergency_swap", + "type": "u32" + }, + { + "name": "min_withdrawal_fee_bps", + "type": "u64" + }, + { + "name": "scope_program_id", + "type": "pubkey" + }, + { + "name": "deprecated", + "type": "pubkey" + }, + { + "name": "padding0_non_zeroed", + "type": { + "array": [ + "u64", + 256 + ] + } + }, + { + "name": "actions_authority", + "type": "pubkey" + }, + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "treasury_fee_vaults", + "type": { + "array": [ + "pubkey", + 256 + ] + } + }, + { + "name": "token_infos", + "type": "pubkey" + }, + { + "name": "block_local_admin", + "type": "u64" + }, + { + "name": "min_performance_fee_bps", + "type": "u64" + }, + { + "name": "min_swap_uneven_slippage_tolerance_bps", + "type": "u64" + }, + { + "name": "min_reference_price_slippage_tolerance_bps", + "type": "u64" + }, + { + "name": "actions_after_rebalance_delay_seconds", + "type": "u64" + }, + { + "name": "treasury_fee_vault_receiver", + "type": "pubkey" + }, + { + "name": "scope_price_ids", + "type": { + "array": [ + "pubkey", + 16 + ] + } + }, + { + "name": "max_deviation_from_ref_price_on_invest_bps", + "type": "u32" + }, + { + "name": "padding1", + "type": "u32" + }, + { + "name": "invest_cooldown_slots", + "type": "u64" + }, + { + "name": "min_invest_trigger_value_usd", + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 1968 + ] + } + } + ] + } + }, + { + "name": "CollateralInfos", + "type": { + "kind": "struct", + "fields": [ + { + "name": "infos", + "type": { + "array": [ + { + "defined": { + "name": "CollateralInfo" + } + }, + 303 + ] + } + } + ] + } + }, + { + "name": "ScopeChainAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chain_array", + "type": { + "array": [ + { + "array": [ + "u16", + 4 + ] + }, + 512 + ] + } + } + ] + } + }, + { + "name": "TermsSignature", + "type": { + "kind": "struct", + "fields": [ + { + "name": "signature", + "type": { + "array": [ + "u8", + 64 + ] + } + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml new file mode 100644 index 000000000..856890193 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml @@ -0,0 +1,231 @@ +protocol: kamino-liquidity +version: v0.1.0 +account_type: WhirlpoolStrategy +idl_file_path: idl.json + +tags: + - liquidity + - concentrated-liquidity + - yield + - defi + +templates: + - id: kamino-liquidity-strategy-balances + name: Override Liquidity Strategy Balances + description: Override a Kamino Liquidity strategy's holdings and shares + idl_account_name: WhirlpoolStrategy + properties: + - path: token_a_amounts + label: Idle token A + description: "Token A held outside the position, in the mint's smallest unit. Example: 1000000000" + - path: token_b_amounts + label: Idle token B + description: "Token B held outside the position, in the mint's smallest unit. Example: 1000000" + - path: shares_issued + label: Shares outstanding + description: "Total shares held by depositors. Example: 1000000000" + - path: position_timestamp + label: Position opened + description: "When the current position was opened (unix seconds). Example: 1780000000" + - path: last_invest_slot + label: Last invest slot + description: "Slot at which the strategy last deployed idle funds into the pool. Example: 370000000" + address: + type: pubkey + llm_context: | + Share price = total holdings (idle plus what is inside the position) / shares_issued. + + HOW TO USE THIS TEMPLATE: + 1. Raise token_a_amounts / token_b_amounts alone to simulate the strategy earning fees + 2. Raise shares_issued alone to dilute holders + 3. Pair with the underlying pool's own template - strategy_dex on + kamino-liquidity-strategy-guards says whether that is whirlpool-*, raydium-clmm-* or + meteora-* + + The tick range and in-range liquidity live on the DEX's own position account, owned by Orca or + Raydium rather than Kamino, so override those through that protocol. + + EXAMPLE - "the strategy collected 1 SOL of fees": + token_a_amounts: 1000000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-liquidity-strategy-rewards + name: Override Liquidity Strategy Rewards + description: Override Kamino Liquidity strategy reward balances + idl_account_name: WhirlpoolStrategy + properties: + - path: reward0_amount + label: DEX reward 0 + description: >- + Rewards harvested from the underlying DEX pool for slot 0, in that reward token's smallest + unit. Example: 1000000 + - path: reward1_amount + label: DEX reward 1 + description: "Harvested DEX pool rewards for slot 1, smallest unit. Example: 1000000" + - path: reward2_amount + label: DEX reward 2 + description: "Harvested DEX pool rewards for slot 2, smallest unit. Example: 1000000" + - path: kamino_rewards.0.reward_per_second + label: Kamino rate + description: >- + Kamino's own emission rate for this slot, in the reward token's smallest unit per second. + Example: 1000 + - path: kamino_rewards.0.amount_uncollected + label: Accrued, undistributed + description: "Rewards accrued but not yet distributed. Example: 5000000" + - path: kamino_rewards.0.amount_available + label: Budget remaining + description: "Reward budget left for this slot. Example: 1000000000" + - path: kamino_rewards.0.last_issuance_ts + label: Last issuance time + description: "When this slot last accrued (unix seconds). Example: 1780000000" + - path: kamino_rewards.0.reward_mint + label: Kamino reward mint + description: >- + Token this Kamino reward slot pays out. Example: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v + (USDC) + - path: farm + label: Linked farm + description: >- + Kamino Farms account if this strategy also pays through Farms. Example: the farm's address + address: + type: pubkey + llm_context: | + TWO reward streams: reward0_amount through reward2_amount are harvested from the underlying + DEX pool, while kamino_rewards holds Kamino's own emissions on top (3 slots). + + HOW TO USE THIS TEMPLATE: + 1. Replace the 0 in kamino_rewards paths with the slot you want (0-2) + 2. Raising kamino_rewards.N.amount_uncollected is the quickest way to give a strategy a + pending reward to hand out + 3. When farm is set, the strategy also pays through Kamino Farms - use the kamino-farms-* + templates for the per-user side + + EXAMPLE - "strategy has rewards ready to distribute": + kamino_rewards.0.amount_uncollected: 5000000 + kamino_rewards.0.amount_available: 1000000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-liquidity-strategy-guards + name: Override Liquidity Strategy Guards + description: Override Kamino Liquidity strategy caps and slippage guards + idl_account_name: WhirlpoolStrategy + properties: + - path: status + label: Strategy status + description: Strategy lifecycle state; unlabelled in the IDL, keep as found + - path: strategy_type + label: Strategy type + description: Rebalancing style; unlabelled in the IDL, keep as found + - path: strategy_dex + label: Underlying DEX + description: >- + Which venue the position runs on: 0 = Orca Whirlpool, 1 = Raydium CLMM, 2 = Meteora. Example: + 0 + - path: deposit_blocked + label: Deposits blocked + description: "1 blocks new deposits, 0 allows them. Example: 1" + - path: withdraw_blocked + label: Withdrawals blocked + description: "1 blocks withdrawals, 0 allows them. Example: 1" + - path: invest_blocked + label: Investing blocked + description: >- + 1 stops the strategy deploying idle funds into the pool, leaving deposits sitting in the + vaults. Example: 1 + - path: creation_status + label: Creation status + description: Setup progress; unlabelled in the IDL, keep as found + - path: allow_deposit_without_invest + label: Deposit without invest + description: "1 lets a deposit settle without immediately deploying into the pool. Example: 1" + - path: flash_vault_swap_allowed + label: Flash swap allowed + description: >- + 1 permits flash swaps through the strategy vaults, the path an arbitrage bot uses. Example: 1 + - path: deposit_cap_usd + label: Deposit cap (USD) + description: "Total deposit ceiling in whole US dollars. Example: 0" + - path: deposit_cap_usd_per_ixn + label: Per-deposit cap (USD) + description: "Ceiling for a single deposit instruction, in whole US dollars. Example: 1000" + - path: max_price_deviation_bps + label: Max price deviation + description: >- + Max pool-price deviation from reference before the strategy refuses to act, in bps. Example: + 2000 + - path: max_deviation_from_ref_price_on_invest_bps + label: Max deviation on invest + description: "Same guard, applied when deploying idle funds in bps. Example: 2000" + - path: swap_vault_max_slippage_bps + label: Max swap slippage + description: "Slippage tolerated on an internal rebalancing swap in bps. Example: 100" + - path: swap_vault_max_slippage_from_reference_bps + label: Max slippage vs reference + description: "Slippage tolerated against the reference price on an internal swap in bps. Example: 100" + - path: rebalance_type + label: Rebalance type + description: Which rule picks new tick bounds; unlabelled in the IDL, keep as found + - path: withdrawal_cap_a.config_capacity + label: Token A withdrawal cap + description: "Maximum token A withdrawable per interval, smallest unit. Example: -1" + - path: withdrawal_cap_a.current_total + label: Token A withdrawn so far + description: "Running total withdrawn in the current interval. Example: 0" + - path: withdrawal_cap_b.config_capacity + label: Token B withdrawal cap + description: "Maximum token B withdrawable per interval, smallest unit. Example: -1" + - path: withdrawal_cap_b.current_total + label: Token B withdrawn so far + description: "Running total of token B withdrawn this interval. Example: 0" + address: + type: pubkey + llm_context: | + strategy_dex tells you which pool template to pair this with: 0 = Orca Whirlpool, + 1 = Raydium CLMM, 2 = Meteora. + + HOW TO USE THIS TEMPLATE: + 1. If you move the underlying pool price and the transaction is rejected, raise + max_price_deviation_bps and max_deviation_from_ref_price_on_invest_bps + 2. Set a withdrawal_cap_*.config_capacity of -1 to disable that cap + 3. deposit_blocked / withdraw_blocked / invest_blocked are 0/1 switches + + EXAMPLE - "let a 20% pool price move through": + max_price_deviation_bps: 2000 + max_deviation_from_ref_price_on_invest_bps: 2000 + + - id: kamino-liquidity-strategy-fees + name: Override Liquidity Strategy Fees + description: Override the Kamino Liquidity strategy's cut of fees and rewards + idl_account_name: WhirlpoolStrategy + properties: + - path: withdraw_fee + label: Withdrawal fee + description: "Charged when a depositor exits in bps. Example: 0" + - path: fees_fee + label: Fee share + description: "Kamino's cut of trading fees earned by the position in bps. Example: 0" + - path: reward0_fee + label: Reward 0 fee + description: "Kamino's cut of reward slot 0 in bps. Example: 0" + - path: reward1_fee + label: Reward 1 fee + description: "Kamino's cut of reward slot 1 in bps. Example: 0" + - path: reward2_fee + label: Reward 2 fee + description: "Kamino's cut of reward slot 2 in bps. Example: 0" + address: + type: pubkey + llm_context: | + Use this template to remove protocol fees so an expected share price is easier to assert on. + + EXAMPLE - "no fees at all": + withdraw_fee: 0 + fees_fee: 0 + reward0_fee: 0 + reward1_fee: 0 + reward2_fee: 0 \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json new file mode 100644 index 000000000..b5133b286 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/scope/v1/idl.json @@ -0,0 +1,1590 @@ +{ + "address": "HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ", + "metadata": { + "name": "scope", + "version": "0.39.0", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Configuration", + "discriminator": [ + 192, + 79, + 172, + 30, + 21, + 173, + 25, + 43 + ] + }, + { + "name": "MintsToScopeChains", + "discriminator": [ + 156, + 236, + 56, + 20, + 39, + 141, + 42, + 183 + ] + }, + { + "name": "OracleMappings", + "discriminator": [ + 40, + 244, + 110, + 80, + 255, + 214, + 243, + 188 + ] + }, + { + "name": "OraclePrices", + "discriminator": [ + 89, + 128, + 118, + 221, + 6, + 72, + 180, + 146 + ] + }, + { + "name": "OracleTwaps", + "discriminator": [ + 192, + 139, + 27, + 250, + 53, + 166, + 101, + 61 + ] + }, + { + "name": "TokenMetadatas", + "discriminator": [ + 221, + 107, + 64, + 103, + 67, + 0, + 165, + 22 + ] + } + ], + "types": [ + { + "name": "UpdateOracleMappingAndMetadataEntriesWithId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "entry_id", + "type": "u16" + }, + { + "name": "updates", + "type": { + "vec": { + "defined": { + "name": "UpdateOracleMappingAndMetadataEntry" + } + } + } + } + ] + } + }, + { + "name": "CappedFlooredData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entry", + "type": "u16" + }, + { + "name": "cap_entry", + "type": { + "option": "u16" + } + }, + { + "name": "floor_entry", + "type": { + "option": "u16" + } + } + ] + } + }, + { + "name": "CappedMostRecentOfData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entries", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "max_divergence_bps", + "type": "u16" + }, + { + "name": "sources_max_age_s", + "type": "u64" + }, + { + "name": "cap_entry", + "type": "u16" + } + ] + } + }, + { + "name": "V3", + "type": { + "kind": "struct", + "fields": [ + { + "name": "confidence_factor", + "type": "u32" + } + ] + } + }, + { + "name": "V8V10", + "type": { + "kind": "struct", + "fields": [ + { + "name": "market_status_behavior", + "type": { + "defined": { + "name": "MarketStatusBehavior" + } + } + } + ] + } + }, + { + "name": "ChainlinkStandardPriceData", + "docs": [ + "Price data for standard Chainlink types (v3, v7, v8, v9)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "observations_timestamp", + "type": "u64" + } + ] + } + }, + { + "name": "ChainlinkXPriceData", + "docs": [ + "Price data for ChainlinkX type (v10)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "observations_timestamp", + "type": "u64" + }, + { + "name": "suspended", + "type": "bool" + }, + { + "name": "activation_date_time", + "type": "u64" + } + ] + } + }, + { + "name": "ConditionalData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "condition", + "type": "u8" + }, + { + "name": "tolerance_bps", + "type": "u16" + }, + { + "name": "sources", + "docs": [ + "Extension-prone source list is stored last so future versioned layouts can", + "add more sources without shifting earlier scalar fields." + ], + "type": { + "array": [ + "u16", + 3 + ] + } + } + ] + } + }, + { + "name": "DiscountToMaturityData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "discount_per_year_bps", + "type": "u16" + }, + { + "name": "maturity_timestamp", + "type": "i64" + } + ] + } + }, + { + "name": "MostRecentOfData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entries", + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "max_divergence_bps", + "type": "u16" + }, + { + "name": "sources_max_age_s", + "type": "u64" + } + ] + } + }, + { + "name": "Fee", + "type": { + "kind": "struct", + "fields": [ + { + "name": "basis_points", + "type": "u32" + } + ] + } + }, + { + "name": "LiqPool", + "type": { + "kind": "struct", + "fields": [ + { + "name": "lp_mint", + "type": "pubkey" + }, + { + "name": "lp_mint_authority_bump_seed", + "type": "u8" + }, + { + "name": "sol_leg_bump_seed", + "type": "u8" + }, + { + "name": "msol_leg_authority_bump_seed", + "type": "u8" + }, + { + "name": "msol_leg", + "type": "pubkey" + }, + { + "name": "lp_liquidity_target", + "docs": [ + "Liquidity target. If the Liquidity reach this amount, the fee reaches lp_min_discount_fee" + ], + "type": "u64" + }, + { + "name": "lp_max_fee", + "docs": [ + "Liquidity pool max fee" + ], + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "lp_min_fee", + "docs": [ + "SOL/mSOL Liquidity pool min fee" + ], + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "treasury_cut", + "docs": [ + "Treasury cut" + ], + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "lp_supply", + "type": "u64" + }, + { + "name": "lent_from_sol_leg", + "type": "u64" + }, + { + "name": "liquidity_sol_cap", + "type": "u64" + } + ] + } + }, + { + "name": "List", + "type": { + "kind": "struct", + "fields": [ + { + "name": "account", + "type": "pubkey" + }, + { + "name": "item_size", + "type": "u32" + }, + { + "name": "count", + "type": "u32" + }, + { + "name": "new_account", + "type": "pubkey" + }, + { + "name": "copied_count", + "type": "u32" + } + ] + } + }, + { + "name": "StakeSystem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "stake_list", + "type": { + "defined": { + "name": "List" + } + } + }, + { + "name": "delayed_unstake_cooling_down", + "type": "u64" + }, + { + "name": "stake_deposit_bump_seed", + "type": "u8" + }, + { + "name": "stake_withdraw_bump_seed", + "type": "u8" + }, + { + "name": "slots_for_stake_delta", + "docs": [ + "set by admin, how much slots before the end of the epoch, stake-delta can start" + ], + "type": "u64" + }, + { + "name": "last_stake_delta_epoch", + "docs": [ + "Marks the start of stake-delta operations, meaning that if somebody starts a delayed-unstake ticket", + "after this var is set with epoch_num the ticket will have epoch_created = current_epoch+1", + "(the user must wait one more epoch, because their unstake-delta will be execute in this epoch)" + ], + "type": "u64" + }, + { + "name": "min_stake", + "type": "u64" + }, + { + "name": "extra_stake_delta_runs", + "docs": [ + "can be set by validator-manager-auth to allow a second run of stake-delta to stake late stakers in the last minute of the epoch", + "so we maximize user's rewards" + ], + "type": "u32" + } + ] + } + }, + { + "name": "ValidatorSystem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "validator_list", + "type": { + "defined": { + "name": "List" + } + } + }, + { + "name": "manager_authority", + "type": "pubkey" + }, + { + "name": "total_validator_score", + "type": "u32" + }, + { + "name": "total_active_balance", + "docs": [ + "sum of all active lamports staked" + ], + "type": "u64" + }, + { + "name": "auto_add_validator_enabled", + "docs": [ + "allow & auto-add validator when a user deposits a stake-account of a non-listed validator" + ], + "type": "u8" + } + ] + } + }, + { + "name": "State", + "type": { + "kind": "struct", + "fields": [ + { + "name": "msol_mint", + "type": "pubkey" + }, + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "operational_sol_account", + "type": "pubkey" + }, + { + "name": "treasury_msol_account", + "type": "pubkey" + }, + { + "name": "reserve_bump_seed", + "type": "u8" + }, + { + "name": "msol_mint_authority_bump_seed", + "type": "u8" + }, + { + "name": "rent_exempt_for_token_acc", + "type": "u64" + }, + { + "name": "reward_fee", + "type": { + "defined": { + "name": "Fee" + } + } + }, + { + "name": "stake_system", + "type": { + "defined": { + "name": "StakeSystem" + } + } + }, + { + "name": "validator_system", + "type": { + "defined": { + "name": "ValidatorSystem" + } + } + }, + { + "name": "liq_pool", + "type": { + "defined": { + "name": "LiqPool" + } + } + }, + { + "name": "available_reserve_balance", + "type": "u64" + }, + { + "name": "msol_supply", + "type": "u64" + }, + { + "name": "msol_price", + "type": "u64" + }, + { + "name": "circulating_ticket_count", + "docs": [ + "count tickets for delayed-unstake" + ], + "type": "u64" + }, + { + "name": "circulating_ticket_balance", + "docs": [ + "total lamports amount of generated and not claimed yet tickets" + ], + "type": "u64" + }, + { + "name": "lent_from_reserve", + "type": "u64" + }, + { + "name": "min_deposit", + "type": "u64" + }, + { + "name": "min_withdraw", + "type": "u64" + }, + { + "name": "staking_sol_cap", + "type": "u64" + }, + { + "name": "emergency_cooling_down", + "type": "u64" + } + ] + } + }, + { + "name": "MultiplicationChainData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entries", + "type": { + "array": [ + "u16", + 6 + ] + } + }, + { + "name": "sources_max_age_s", + "type": "u64" + } + ] + } + }, + { + "name": "PythLazerData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "feed_id", + "type": "u16" + }, + { + "name": "exponent", + "type": "u8" + }, + { + "name": "bid_ask_spread_factor", + "docs": [ + "Tolerance factor for the bid/ask spread check (`ask - bid` against the", + "price). `0` disables the spread check entirely, in which case the payload", + "is not required to carry `BestBidPrice`/`BestAskPrice`." + ], + "type": "u32" + }, + { + "name": "ema_enabled", + "type": "bool" + }, + { + "name": "ema_confidence_factor", + "type": "u32" + }, + { + "name": "price_confidence_factor", + "docs": [ + "Tolerance factor for the native Lazer `Confidence` check; `0` disables it." + ], + "type": "u32" + } + ] + } + }, + { + "name": "PythLazerEmaRefData", + "docs": [ + "Reference-oracle config for `OracleType::PythLazerEMA`.", + "", + "Stored in `oracle_mappings.generic[index]` for an EMA entry. The EMA value", + "itself lives in the source `PythLazer` entry's `dated_price.generic_data`,", + "populated by `update_price` whenever the spot refresh payload includes an", + "`EmaPrice` property. See `get_ema_price`." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "source_entry", + "docs": [ + "Token index of the source `PythLazer` entry to read the EMA from." + ], + "type": "u16" + } + ] + } + }, + { + "name": "PythLazerStoredData", + "docs": [ + "Layout of `DatedPrice.generic_data` (24 bytes) for `PythLazer` entries.", + "", + "`update_price` writes the spot feed timestamp on every refresh, and the EMA", + "fields whenever the payload carries an `EmaPrice`. `ema_feed_update_timestamp_us == 0`", + "is the \"EMA never received\" sentinel consumed by `get_ema_price`." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "spot_feed_update_timestamp_us", + "type": "u64" + }, + { + "name": "ema_price_value", + "type": "u64" + }, + { + "name": "ema_feed_update_timestamp_us", + "type": "u64" + } + ] + } + }, + { + "name": "Price", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": "u64" + }, + { + "name": "exp", + "type": "u64" + } + ] + } + }, + { + "name": "DatedPrice", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price", + "type": { + "defined": { + "name": "Price" + } + } + }, + { + "name": "last_updated_slot", + "type": "u64" + }, + { + "name": "unix_timestamp", + "type": "u64" + }, + { + "name": "generic_data", + "type": { + "array": [ + "u8", + 24 + ] + } + } + ] + } + }, + { + "name": "MintToScopeChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "scope_chain", + "type": { + "array": [ + "u16", + 4 + ] + } + } + ] + } + }, + { + "name": "EmaTwap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_update_slot", + "type": "u64" + }, + { + "name": "last_update_unix_timestamp", + "type": "u64" + }, + { + "name": "current_ema1h", + "type": "u128" + }, + { + "name": "updates_tracker1h", + "docs": [ + "The sample tracker is a 64 bit number where each bit represents a point in time." + ], + "type": "u64" + }, + { + "name": "updates_tracker7d", + "type": "u64" + }, + { + "name": "current_ema8h", + "type": "u128" + }, + { + "name": "current_ema24h", + "type": "u128" + }, + { + "name": "updates_tracker8h", + "type": "u64" + }, + { + "name": "updates_tracker24h", + "type": "u64" + }, + { + "name": "current_ema7d", + "type": "u128" + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 35 + ] + } + } + ] + } + }, + { + "name": "TwapEnabledBitmask", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bitmask", + "type": "u8" + } + ] + } + }, + { + "name": "TokenMetadata", + "type": { + "kind": "struct", + "fields": [ + { + "name": "name", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "max_age_price_slots", + "type": "u64" + }, + { + "name": "group_ids_bitset", + "type": "u64" + }, + { + "name": "reserved", + "type": { + "array": [ + "u64", + 15 + ] + } + } + ] + } + }, + { + "name": "UpdateOracleMappingAndMetadataEntry", + "type": { + "kind": "enum", + "variants": [ + { + "name": "RemoveEntry" + }, + { + "name": "MappingConfig", + "fields": [ + { + "name": "price_type", + "type": { + "defined": { + "name": "OracleType" + } + } + }, + { + "name": "generic_data", + "type": { + "array": [ + "u8", + 20 + ] + } + } + ] + }, + { + "name": "MappingTwapEntry", + "fields": [ + { + "name": "price_type", + "type": { + "defined": { + "name": "OracleType" + } + } + }, + { + "name": "twap_source", + "type": "u16" + } + ] + }, + { + "name": "MappingTwapEnabledBitmask", + "fields": [ + "u8" + ] + }, + { + "name": "MappingRefPrice", + "fields": [ + { + "name": "ref_price_index", + "type": { + "option": "u16" + } + }, + { + "name": "ref_price_tolerance_bps", + "type": { + "option": "u16" + } + } + ] + }, + { + "name": "MetadataName", + "fields": [ + "string" + ] + }, + { + "name": "MetadataMaxPriceAgeSlots", + "fields": [ + "u64" + ] + }, + { + "name": "MetadataGroupIdsBitset", + "fields": [ + "u64" + ] + } + ] + } + }, + { + "name": "ReportDataMarketStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Unknown" + }, + { + "name": "Closed" + }, + { + "name": "Open" + } + ] + } + }, + { + "name": "MarketStatusBehavior", + "type": { + "kind": "enum", + "variants": [ + { + "name": "AllUpdates" + }, + { + "name": "Open" + }, + { + "name": "OpenAndPrePost" + } + ] + } + }, + { + "name": "ReportDataV9RipcordFlag", + "docs": [ + "# Ripcord Flag", + "- `0` (false): Feed's data provider is OK. Fund's data provider and accuracy is as expected.", + "- `1` (true): Feed's data provider is flagging a pause. Data provider detected outliers,", + "deviated thresholds, or operational issues. **DO NOT consume NAV data when ripcord=1.**" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Normal" + }, + { + "name": "Paused" + } + ] + } + }, + { + "name": "PriceUpdateResult", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Updated" + }, + { + "name": "SuspendExistingPrice" + } + ] + } + }, + { + "name": "Condition", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Gt" + }, + { + "name": "Gte" + }, + { + "name": "Lt" + }, + { + "name": "Lte" + }, + { + "name": "Eq" + }, + { + "name": "Neq" + }, + { + "name": "WithinRangeAbs" + }, + { + "name": "OutsideRangeAbs" + }, + { + "name": "WithinRangeBps" + }, + { + "name": "OutsideRangeBps" + }, + { + "name": "NonZero" + } + ] + } + }, + { + "name": "TokenTypes", + "type": { + "kind": "enum", + "variants": [ + { + "name": "TokenA" + }, + { + "name": "TokenB" + } + ] + } + }, + { + "name": "RefPriceToleranceOrTwapSource", + "type": { + "kind": "enum", + "variants": [ + { + "name": "None" + }, + { + "name": "RefPriceToleranceBps", + "fields": [ + "u16" + ] + }, + { + "name": "TwapSource", + "fields": [ + "u16" + ] + } + ] + } + }, + { + "name": "EmaType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Ema1h" + }, + { + "name": "Ema8h" + }, + { + "name": "Ema24h" + }, + { + "name": "Ema7d" + } + ] + } + }, + { + "name": "OracleType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Unused" + }, + { + "name": "DeprecatedPlaceholder1" + }, + { + "name": "DeprecatedPlaceholder2" + }, + { + "name": "DeprecatedPlaceholder3" + }, + { + "name": "DeprecatedPlaceholder4" + }, + { + "name": "SplStake" + }, + { + "name": "KToken" + }, + { + "name": "DeprecatedPlaceholder5" + }, + { + "name": "MsolStake" + }, + { + "name": "KTokenToTokenA" + }, + { + "name": "KTokenToTokenB" + }, + { + "name": "JupiterLpFetch" + }, + { + "name": "ScopeTwap1h" + }, + { + "name": "OrcaWhirlpoolAtoB" + }, + { + "name": "OrcaWhirlpoolBtoA" + }, + { + "name": "RaydiumAmmV3AtoB" + }, + { + "name": "RaydiumAmmV3BtoA" + }, + { + "name": "DeprecatedPlaceholder6" + }, + { + "name": "MeteoraDlmmAtoB" + }, + { + "name": "MeteoraDlmmBtoA" + }, + { + "name": "DeprecatedPlaceholder7" + }, + { + "name": "PythPull" + }, + { + "name": "PythPullEMA" + }, + { + "name": "FixedPrice" + }, + { + "name": "SwitchboardOnDemand" + }, + { + "name": "JitoRestaking" + }, + { + "name": "Chainlink" + }, + { + "name": "DiscountToMaturity" + }, + { + "name": "MostRecentOf" + }, + { + "name": "PythLazer" + }, + { + "name": "RedStone" + }, + { + "name": "AdrenaLp" + }, + { + "name": "Securitize" + }, + { + "name": "CappedFloored" + }, + { + "name": "ChainlinkRWA" + }, + { + "name": "ChainlinkNAV" + }, + { + "name": "FlashtradeLp" + }, + { + "name": "ChainlinkX" + }, + { + "name": "ChainlinkExchangeRate" + }, + { + "name": "CappedMostRecentOf" + }, + { + "name": "ScopeTwap8h" + }, + { + "name": "ScopeTwap24h" + }, + { + "name": "ScopeTwap7d" + }, + { + "name": "MultiplicationChain" + }, + { + "name": "SplBalance" + }, + { + "name": "StakedSolBalance" + }, + { + "name": "TotalMintSupply" + }, + { + "name": "Conditional" + }, + { + "name": "PythLazerEMA" + } + ] + } + }, + { + "name": "ScopeChainError", + "docs": [ + "Errors that can be raised while creating or manipulating a scope chain" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "PriceChainTooLong" + }, + { + "name": "PriceChainConversionFailure" + }, + { + "name": "NoChainForToken" + }, + { + "name": "InvalidPricesInChain" + }, + { + "name": "MathOverflow" + }, + { + "name": "IntegerConversionOverflow" + } + ] + } + }, + { + "name": "Configuration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "oracle_prices", + "type": "pubkey" + }, + { + "name": "tokens_metadata", + "type": "pubkey" + }, + { + "name": "oracle_twaps", + "type": "pubkey" + }, + { + "name": "admin_cached", + "type": "pubkey" + }, + { + "name": "emergency_council", + "type": "pubkey" + }, + { + "name": "resume_authority", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 1247 + ] + } + } + ] + } + }, + { + "name": "MintsToScopeChains", + "docs": [ + "Map of mints to scope chain only valid for a given price feed" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_prices", + "type": "pubkey" + }, + { + "name": "seed_pk", + "type": "pubkey" + }, + { + "name": "seed_id", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "mapping", + "type": { + "vec": { + "defined": { + "name": "MintToScopeChain" + } + } + } + } + ] + } + }, + { + "name": "OracleMappings", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price_info_accounts", + "type": { + "array": [ + "pubkey", + 512 + ] + } + }, + { + "name": "price_types", + "type": { + "array": [ + "u8", + 512 + ] + } + }, + { + "name": "twap_source_or_ref_price_tolerance_bps", + "type": { + "array": [ + "u16", + 512 + ] + } + }, + { + "name": "twap_enabled_bitmask", + "type": { + "array": [ + { + "defined": { + "name": "TwapEnabledBitmask" + } + }, + 512 + ] + } + }, + { + "name": "ref_price", + "type": { + "array": [ + "u16", + 512 + ] + } + }, + { + "name": "generic", + "type": { + "array": [ + { + "array": [ + "u8", + 20 + ] + }, + 512 + ] + } + } + ] + } + }, + { + "name": "OraclePrices", + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "prices", + "type": { + "array": [ + { + "defined": { + "name": "DatedPrice" + } + }, + 512 + ] + } + } + ] + } + }, + { + "name": "OracleTwaps", + "type": { + "kind": "struct", + "fields": [ + { + "name": "oracle_prices", + "type": "pubkey" + }, + { + "name": "oracle_mappings", + "type": "pubkey" + }, + { + "name": "twaps", + "type": { + "array": [ + { + "defined": { + "name": "EmaTwap" + } + }, + 512 + ] + } + } + ] + } + }, + { + "name": "TokenMetadatas", + "type": { + "kind": "struct", + "fields": [ + { + "name": "metadatas_array", + "type": { + "array": [ + { + "defined": { + "name": "TokenMetadata" + } + }, + 512 + ] + } + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml new file mode 100644 index 000000000..9cb81179b --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml @@ -0,0 +1,127 @@ +protocol: kamino-scope +version: v0.39.0 +account_type: OraclePrices +idl_file_path: idl.json + +tags: + - oracle + - price-feed + - lending + - defi + +templates: + - id: kamino-scope-price + name: Override Scope Price + description: Override a price in Kamino's Scope oracle + idl_account_name: OraclePrices + properties: + - path: prices.0.price.value + label: Price value + description: "The price mantissa. Example: 12550000000" + - path: prices.0.price.exp + label: Price exponent + description: "Decimal exponent for `value`. Example: 8" + - path: prices.0.last_updated_slot + label: Last updated slot + description: "Slot at which this price was published. Example: 370000000" + - path: prices.0.unix_timestamp + label: Last updated time + description: "Publication time (unix seconds). Example: 1800000000" + address: + type: pubkey + # Main Market's price account. Kamino runs several OraclePrices accounts and a reserve + # names its own in `config.token_info.scope_configuration.price_feed` - check there before + # assuming this one. Captured 2026-08-06. + value: 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH + llm_context: | + CRITICAL: This is the correct way to move a Kamino price. A Reserve's + liquidity.market_price_sf is only a cache that refresh_reserve recomputes from Scope. + + HOW TO USE THIS TEMPLATE: + 1. Read the target Reserve's config.token_info.scope_configuration.price_feed and use that + account as the address (the default serves the Main Market) + 2. Read its config.token_info.scope_configuration.price_chain - up to 4 indices, 65535 = unused + 3. Replace the index 0 in the property paths with the entry you want to move. A chain of + [210, 3] means price = prices[210] * prices[3] + 4. Set price.value = usd_price * 10^exp, keeping exp as you found it + 5. Set last_updated_slot and unix_timestamp to now, or Kamino rejects the price as stale + 6. Set persist: true if the scenario runs past one slot, so a transaction that writes + this account cannot restore the real price. Safe here: nothing in a fork cranks Scope + + SCOPE INDICES (verified 2026-08-06, do not guess these): + - 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH (Main Market): + SOL=3, USDC=13, PYUSD=148, cbBTC=175, JitoSOL=[210,3] + - 3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C (JLP Market): SOL=0, JLP=416 + + EXAMPLE - "SOL crashes to $45" on the Main Market: + prices.3.price.value: 4500000000 + prices.3.price.exp: 8 + + - id: kamino-scope-price-source + name: Override Scope Price Source Mapping + description: Override where a Scope index reads its price from + idl_account_name: OracleMappings + properties: + - path: price_info_accounts.0 + label: Upstream oracle account + description: "Upstream feed Scope reads this index from. Example: a Pyth price account" + - path: price_types.0 + label: Source type + description: How Scope interprets the upstream account; unlabelled in the IDL, keep as found + - path: twap_source_or_ref_price_tolerance_bps.0 + label: Deviation tolerance + description: "Max deviation from the reference before Scope rejects a price, in bps. Example: 5000" + - path: ref_price.0 + label: Reference index + description: "Index of the entry used as this one's sanity reference; 65535 means none. Example: 65535" + address: + type: pubkey + llm_context: | + Use this template to change WHERE Scope reads a price from on its next refresh. + Use kamino-scope-price instead to change a stored price directly. + + HOW TO USE THIS TEMPLATE: + 1. Find this account via the oracle_mappings field on the OraclePrices account you target + 2. Replace the index 0 in the property paths with your entry (0-511) + 3. Point price_info_accounts at an upstream feed you control, or raise + twap_source_or_ref_price_tolerance_bps to let an extreme simulated price through + + EXAMPLE - let a 50% price move past the anomaly guard on entry 3: + twap_source_or_ref_price_tolerance_bps.3: 5000 + + - id: kamino-scope-twap + name: Override Scope TWAP + description: Override a Kamino Scope TWAP entry + idl_account_name: OracleTwaps + properties: + - path: twaps.0.current_ema1h + label: 1h EMA + description: "1h EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.current_ema8h + label: 8h EMA + description: "8h EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.current_ema24h + label: 24h EMA + description: "24h EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.current_ema7d + label: 7d EMA + description: "7d EMA (scaled fraction, x2^60). Example: 0.15 x 2^60" + - path: twaps.0.last_update_slot + label: Last updated slot + description: "Slot at which this TWAP entry was last updated. Example: 370000000" + - path: twaps.0.last_update_unix_timestamp + label: Last updated time + description: "When this TWAP entry was last updated (unix seconds). Example: 1800000000" + address: + type: pubkey + llm_context: | + Use this template when a Scope price override is rejected for diverging from its TWAP. + + HOW TO USE THIS TEMPLATE: + 1. Find this account via the oracle_twaps field on the Scope Configuration account + 2. Replace the index 0 in the property paths with the same entry you moved in kamino-scope-price + 3. Move the EMA to match your new spot price, or raise max_twap_divergence_bps on + kamino-reserve-oracle instead + + EXAMPLE - move the 1h EMA of entry 3 to $45 (EMAs are scaled by 2^60): + twaps.3.current_ema1h: "51879434184388608000" diff --git a/crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json new file mode 100644 index 000000000..5dd531b43 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/swap/v1/idl.json @@ -0,0 +1,546 @@ +{ + "address": "LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF", + "metadata": { + "name": "limo", + "version": "0.1.0", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Order", + "discriminator": [ + 134, + 173, + 223, + 185, + 77, + 86, + 28, + 51 + ] + }, + { + "name": "UserSwapBalancesState", + "discriminator": [ + 140, + 228, + 152, + 62, + 231, + 27, + 245, + 198 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + } + ], + "types": [ + { + "name": "OrderStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Active" + }, + { + "name": "Filled" + }, + { + "name": "Cancelled" + } + ] + } + }, + { + "name": "OrderType", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Vanilla" + } + ] + } + }, + { + "name": "UpdateGlobalConfigMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdateEmergencyMode" + }, + { + "name": "UpdateFlashTakeOrderBlocked" + }, + { + "name": "UpdateBlockNewOrders" + }, + { + "name": "UpdateBlockOrderTaking" + }, + { + "name": "UpdateHostFeeBps" + }, + { + "name": "UpdateAdminAuthorityCached" + }, + { + "name": "UpdateOrderTakingPermissionless" + }, + { + "name": "UpdateOrderCloseDelaySeconds" + }, + { + "name": "UpdateTxnFeeCost" + }, + { + "name": "UpdateAtaCreationCost" + } + ] + } + }, + { + "name": "UpdateGlobalConfigValue", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Bool", + "fields": [ + "bool" + ] + }, + { + "name": "U16", + "fields": [ + "u16" + ] + }, + { + "name": "U64", + "fields": [ + "u64" + ] + }, + { + "name": "Pubkey", + "fields": [ + "pubkey" + ] + } + ] + } + }, + { + "name": "UpdateOrderMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "UpdatePermissionless" + }, + { + "name": "UpdateCounterparty" + } + ] + } + }, + { + "name": "Order", + "type": { + "kind": "struct", + "fields": [ + { + "name": "global_config", + "type": "pubkey" + }, + { + "name": "maker", + "type": "pubkey" + }, + { + "name": "input_mint", + "type": "pubkey" + }, + { + "name": "input_mint_program_id", + "type": "pubkey" + }, + { + "name": "output_mint", + "type": "pubkey" + }, + { + "name": "output_mint_program_id", + "type": "pubkey" + }, + { + "name": "initial_input_amount", + "docs": [ + "The amount of input token the maker wants to swap" + ], + "type": "u64" + }, + { + "name": "expected_output_amount", + "docs": [ + "The amount of output token the maker wants to receive" + ], + "type": "u64" + }, + { + "name": "remaining_input_amount", + "docs": [ + "The amount of input token remaining to be swapped" + ], + "type": "u64" + }, + { + "name": "filled_output_amount", + "docs": [ + "The amount of output token that the maker has received so far" + ], + "type": "u64" + }, + { + "name": "tip_amount", + "docs": [ + "The amount of tips the maker is due to receive for this order -", + "in lamports, stored in the pda_authority account" + ], + "type": "u64" + }, + { + "name": "number_of_fills", + "docs": [ + "The number of times the order has been filled" + ], + "type": "u64" + }, + { + "name": "order_type", + "type": "u8" + }, + { + "name": "status", + "type": "u8" + }, + { + "name": "in_vault_bump", + "type": "u8" + }, + { + "name": "flash_ix_lock", + "docs": [ + "This is normally set to 0, but can be set to 1 to indicate that the", + "order is part of a flash operation, in whcih case the order can not be", + "modified until the flash operation is completed." + ], + "type": "u8" + }, + { + "name": "permissionless", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 3 + ] + } + }, + { + "name": "last_updated_timestamp", + "type": "u64" + }, + { + "name": "flash_start_taker_output_balance", + "docs": [ + "This is only used for flash operations, and is set to the blanance on the start", + "operation, and than back to 0 on the end operation. It is used to compute the difference", + "between start and end balances in order to compute the amount received from a potential swap" + ], + "type": "u64" + }, + { + "name": "counterparty", + "type": "pubkey" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 15 + ] + } + } + ] + } + }, + { + "name": "UserSwapBalancesState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_lamports", + "type": "u64" + }, + { + "name": "input_ta_balance", + "type": "u64" + }, + { + "name": "output_ta_balance", + "type": "u64" + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "emergency_mode", + "type": "u8" + }, + { + "name": "flash_take_order_blocked", + "type": "u8" + }, + { + "name": "new_orders_blocked", + "type": "u8" + }, + { + "name": "orders_taking_blocked", + "type": "u8" + }, + { + "name": "host_fee_bps", + "type": "u16" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 2 + ] + } + }, + { + "name": "order_close_delay_seconds", + "docs": [ + "The number of seconds after an order has been updated before it can be closed" + ], + "type": "u64" + }, + { + "name": "padding1", + "type": { + "array": [ + "u64", + 9 + ] + } + }, + { + "name": "pda_authority_previous_lamports_balance", + "docs": [ + "The total amount of lamports that were present in the pda_authority last", + "time a program instructions which alters the pda_authority account was", + "executed" + ], + "type": "u64" + }, + { + "name": "total_tip_amount", + "docs": [ + "The total amount of tips that have been paid out - should be at least", + "as much as the total lamports present in the pda_authority account" + ], + "type": "u64" + }, + { + "name": "host_tip_amount", + "docs": [ + "The amount of tips the host is due to receive -", + "in lamports, stored in the pda_authority account" + ], + "type": "u64" + }, + { + "name": "pda_authority", + "type": "pubkey" + }, + { + "name": "pda_authority_bump", + "type": "u64" + }, + { + "name": "admin_authority", + "type": "pubkey" + }, + { + "name": "admin_authority_cached", + "type": "pubkey" + }, + { + "name": "txn_fee_cost", + "type": "u64" + }, + { + "name": "ata_creation_cost", + "type": "u64" + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 241 + ] + } + } + ] + } + }, + { + "name": "OrderDisplay", + "type": { + "kind": "struct", + "fields": [ + { + "name": "initial_input_amount", + "type": "u64" + }, + { + "name": "expected_output_amount", + "type": "u64" + }, + { + "name": "remaining_input_amount", + "type": "u64" + }, + { + "name": "filled_output_amount", + "type": "u64" + }, + { + "name": "tip_amount", + "type": "u64" + }, + { + "name": "number_of_fills", + "type": "u64" + }, + { + "name": "on_event_output_amount_filled", + "type": "u64" + }, + { + "name": "on_event_tip_amount", + "type": "u64" + }, + { + "name": "order_type", + "type": "u8" + }, + { + "name": "status", + "type": "u8" + }, + { + "name": "last_updated_timestamp", + "type": "u64" + } + ] + } + }, + { + "name": "UserSwapBalanceDiffs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_lamports_before", + "type": "u64" + }, + { + "name": "input_ta_balance_before", + "type": "u64" + }, + { + "name": "output_ta_balance_before", + "type": "u64" + }, + { + "name": "user_lamports_after", + "type": "u64" + }, + { + "name": "input_ta_balance_after", + "type": "u64" + }, + { + "name": "output_ta_balance_after", + "type": "u64" + }, + { + "name": "swap_program", + "type": "pubkey" + }, + { + "name": "simulated_swap_amount_out", + "type": "u64" + }, + { + "name": "simulated_ts", + "type": "u64" + }, + { + "name": "minimum_amount_out", + "type": "u64" + }, + { + "name": "swap_amount_in", + "type": "u64" + }, + { + "name": "simulated_amount_out_next_best", + "type": "u64" + }, + { + "name": "aggregator", + "type": "u8" + }, + { + "name": "next_best_aggregator", + "type": "u8" + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml new file mode 100644 index 000000000..9e1e474bd --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml @@ -0,0 +1,114 @@ +protocol: kamino-swap +version: v0.1.0 +account_type: Order +idl_file_path: idl.json + +tags: + - swap + - limit-orders + - defi + +templates: + - id: kamino-swap-order + name: Override Limit Order + description: Override a Kamino limit order's amounts and fill progress + idl_account_name: Order + properties: + - path: maker + label: Maker + description: "Wallet that placed the order and deposited the input tokens. Example: your test wallet" + - path: input_mint + label: Input token + description: >- + Token the maker is giving away. Example: So11111111111111111111111111111111111111112 (wSOL) + - path: output_mint + label: Output token + description: >- + Token the maker wants to receive. Example: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (USDC) + - "initial_input_amount" + - "expected_output_amount" + - "remaining_input_amount" + - "filled_output_amount" + - "tip_amount" + - "number_of_fills" + - path: order_type + label: Order type + description: Order behaviour; unlabelled in the IDL, keep as found + - path: status + label: Order status + description: Strategy lifecycle state; unlabelled in the IDL, keep as found + - path: permissionless + label: Anyone can fill + description: "1 lets any taker fill the order, 0 restricts it to `counterparty`. Example: 1" + - path: counterparty + label: Allowed taker + description: >- + The only wallet permitted to fill when `permissionless` is 0. Example: the taker's wallet + - path: last_updated_timestamp + label: Last updated + description: "When the order last changed (unix seconds). Example: 1800000000" + address: + type: pubkey + llm_context: | + Kamino's Swap tab is powered by LIMO, an on-chain limit order book. + + HOW TO USE THIS TEMPLATE: + 1. Set remaining_input_amount to a fraction of initial_input_amount to simulate a PARTIALLY + filled order, or 0 to make it fully consumed + 2. The implied limit price is expected_output_amount / initial_input_amount - lower the + expected output to make the order fillable at a worse market price + 3. Raise tip_amount to make filling attractive to a bot + 4. Amounts are in each mint's smallest unit, so check the mint's decimals first + + EXAMPLE - "1 SOL order, half filled, cheap for the taker": + initial_input_amount: 1000000000 + remaining_input_amount: 500000000 + expected_output_amount: 100000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-swap-global-config + name: Override Swap Global Config + description: Override Kamino limit order global switches and fees + idl_account_name: GlobalConfig + properties: + - path: emergency_mode + label: Emergency mode + description: "1 blocks deposits, borrows and withdrawals; liquidations still allowed. Example: 1" + - path: new_orders_blocked + label: New orders blocked + description: >- + 1 stops order creation while still allowing existing orders to be filled and cancelled. + Example: 1 + - path: orders_taking_blocked + label: Filling blocked + description: "1 stops orders being filled while still allowing new ones to be placed. Example: 1" + - path: flash_take_order_blocked + label: Flash fills blocked + description: "1 blocks flash fills, the arbitrage path. Example: 1" + - path: host_fee_bps + label: Host fee + description: "The integrator's cut of each fill in bps. Example: 0" + - "order_close_delay_seconds" + - "total_tip_amount" + - "host_tip_amount" + - path: txn_fee_cost + label: Assumed tx fee + description: "Transaction cost the program reimburses a filler, in lamports. Example: 5000" + - path: ata_creation_cost + label: Assumed ATA rent + description: "Token-account rent the program reimburses a filler, in lamports. Example: 2039280" + address: + type: pubkey + llm_context: | + flash_take_order_blocked controls flash fills, where a taker borrows the maker's input inside + one transaction, swaps it elsewhere and returns the output. That is the arbitrage path. + + HOW TO USE THIS TEMPLATE: + 1. Set flash_take_order_blocked: 1 to test the rejection + 2. Or leave it at 0 and pair this with a DEX pool override (whirlpool-*, raydium-*, + meteora-*) to build a profitable route + + EXAMPLE - "halt the order book": + emergency_mode: 1 \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/kamino/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/v1/idl.json index 38ff8c8d8..4c270e49a 100644 --- a/crates/core/src/scenarios/protocols/kamino/v1/idl.json +++ b/crates/core/src/scenarios/protocols/kamino/v1/idl.json @@ -2,49 +2,226 @@ "address": "KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD", "metadata": { "name": "kamino_lending", - "version": "1.12.6", + "version": "1.23.0", "spec": "0.1.0" }, "instructions": [], "accounts": [ { "name": "UserState", - "discriminator": [72, 177, 85, 249, 76, 167, 186, 126] + "discriminator": [ + 72, + 177, + 85, + 249, + 76, + 167, + 186, + 126 + ] }, { "name": "GlobalConfig", - "discriminator": [149, 8, 156, 202, 160, 252, 176, 217] + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] }, { "name": "LendingMarket", - "discriminator": [246, 114, 50, 98, 72, 157, 28, 120] + "discriminator": [ + 246, + 114, + 50, + 98, + 72, + 157, + 28, + 120 + ] }, { "name": "Obligation", - "discriminator": [168, 206, 141, 106, 88, 76, 172, 167] + "discriminator": [ + 168, + 206, + 141, + 106, + 88, + 76, + 172, + 167 + ] }, { "name": "ReferrerState", - "discriminator": [194, 81, 217, 103, 12, 19, 12, 66] + "discriminator": [ + 194, + 81, + 217, + 103, + 12, + 19, + 12, + 66 + ] }, { "name": "ReferrerTokenState", - "discriminator": [39, 15, 208, 77, 32, 195, 105, 56] + "discriminator": [ + 39, + 15, + 208, + 77, + 32, + 195, + 105, + 56 + ] }, { "name": "ShortUrl", - "discriminator": [28, 89, 174, 25, 226, 124, 126, 212] + "discriminator": [ + 28, + 89, + 174, + 25, + 226, + 124, + 126, + 212 + ] }, { "name": "UserMetadata", - "discriminator": [157, 214, 220, 235, 98, 135, 171, 28] + "discriminator": [ + 157, + 214, + 220, + 235, + 98, + 135, + 171, + 28 + ] }, { "name": "Reserve", - "discriminator": [43, 242, 204, 202, 26, 247, 59, 127] + "discriminator": [ + 43, + 242, + 204, + 202, + 26, + 247, + 59, + 127 + ] + }, + { + "name": "WithdrawTicket", + "discriminator": [ + 237, + 23, + 164, + 58, + 53, + 248, + 240, + 94 + ] } ], "types": [ + { + "name": "ReserveConfigCustomizationArgs", + "docs": [ + "A definition of optional customizations that should be applied after cloning the config." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "override_fixed_rate_bps", + "docs": [ + "A gate for [Self::fixed_borrow_rate_bps]." + ], + "type": "u8" + }, + { + "name": "fixed_borrow_rate_bps", + "docs": [ + "If [Self::override_fixed_rate_bps] is non-zero, this borrow rate will be used to override", + "the [ReserveConfig::borrow_rate_curve] with a fixed one." + ], + "type": "u32" + }, + { + "name": "override_debt_term_seconds", + "docs": [ + "A gate for [Self::debt_term_seconds]." + ], + "type": "u8" + }, + { + "name": "debt_term_seconds", + "docs": [ + "If [Self::override_debt_term_seconds] is non-zero, this value will be used to override the", + "[ReserveConfig::debt_term_seconds]." + ], + "type": "u64" + }, + { + "name": "clear_elevation_groups", + "docs": [ + "Whether the target reserve should have zeroed [ReserveConfig::elevation_groups] (i.e. not", + "cloned from source).", + "", + "This customization is mandatory when cloning a reserve (with some elevation groups) into a", + "different market (where those elevation group indices would have different meaning)." + ], + "type": "u8" + } + ] + } + }, + { + "name": "BorrowOrderConfigArgs", + "docs": [ + "A subset of [BorrowOrderConfig] excluding the accounts passed via [SetBorrowOrder]." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "remaining_debt_amount", + "type": "u64" + }, + { + "name": "max_borrow_rate_bps", + "type": "u32" + }, + { + "name": "min_debt_term_seconds", + "type": "u64" + }, + { + "name": "fillable_until_timestamp", + "type": "u64" + }, + { + "name": "enable_auto_rollover_on_filled_borrows", + "type": "bool" + } + ] + } + }, { "name": "UpdateConfigMode", "type": { @@ -123,7 +300,7 @@ "name": "UpdateBorrowRateCurve" }, { - "name": "UpdateEntireReserveConfig" + "name": "DeprecatedUpdateEntireReserveConfig" }, { "name": "UpdateDebtWithdrawalCap" @@ -150,7 +327,7 @@ "name": "UpdateBorrowFactor" }, { - "name": "UpdateAssetTier" + "name": "DeprecatedUpdateAssetTier" }, { "name": "UpdateElevationGroup" @@ -208,6 +385,24 @@ }, { "name": "UpdateBlockCTokenUsage" + }, + { + "name": "UpdateDebtMaturityTimestamp" + }, + { + "name": "UpdateDebtTermSeconds" + }, + { + "name": "UpdateEarlyRepayRemainingInterestPct" + }, + { + "name": "UpdateReserveEmergencyMode" + }, + { + "name": "UpdateRewardsAmountPerSlot" + }, + { + "name": "UpdateReservePermissionedOps" } ] } @@ -219,35 +414,50 @@ "variants": [ { "name": "Bool", - "fields": ["bool"] + "fields": [ + "bool" + ] }, { "name": "U8", - "fields": ["u8"] + "fields": [ + "u8" + ] }, { "name": "U8Array", "fields": [ { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } ] }, { "name": "U16", - "fields": ["u16"] + "fields": [ + "u16" + ] }, { "name": "U64", - "fields": ["u64"] + "fields": [ + "u64" + ] }, { "name": "U128", - "fields": ["u128"] + "fields": [ + "u128" + ] }, { "name": "Pubkey", - "fields": ["pubkey"] + "fields": [ + "pubkey" + ] }, { "name": "ElevationGroup", @@ -263,7 +473,10 @@ "name": "Name", "fields": [ { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } ] } @@ -294,7 +507,7 @@ "name": "UpdateGlobalAllowedBorrow" }, { - "name": "UpdateRiskCouncil" + "name": "UpdateEmergencyCouncil" }, { "name": "UpdateMinFullLiquidationThreshold" @@ -355,6 +568,63 @@ }, { "name": "UpdatePriceTriggeredLiquidationDisabled" + }, + { + "name": "UpdateMatureReserveDebtLiquidationEnabled" + }, + { + "name": "UpdateObligationBorrowDebtTermLiquidationEnabled" + }, + { + "name": "UpdateBorrowOrderCreationEnabled" + }, + { + "name": "UpdateBorrowOrderExecutionEnabled" + }, + { + "name": "UpdateMinBorrowOrderFillValue" + }, + { + "name": "UpdateWithdrawTicketIssuanceEnabled" + }, + { + "name": "UpdateWithdrawTicketRedemptionEnabled" + }, + { + "name": "UpdateMinWithdrawQueuedLiquidityValue" + }, + { + "name": "UpdateFixedTermRolloverWindowDurationSeconds" + }, + { + "name": "UpdateOpenTermRolloverWindowDurationSeconds" + }, + { + "name": "UpdateObligationBorrowRolloverConfigurationEnabled" + }, + { + "name": "UpdateTermBasedFullLiquidationDurationSecs" + }, + { + "name": "UpdateObligationBorrowMigrationToFixedExecutionEnabled" + }, + { + "name": "UpdateMinPartialRolloverValue" + }, + { + "name": "UpdateWithdrawTicketCancellationEnabled" + }, + { + "name": "UpdatePermissioningAuthority" + }, + { + "name": "UpdatePermissionedOps" + }, + { + "name": "DeprecatedUpdateReserveRewardsMaxAprPct" + }, + { + "name": "UpdateReserveRewardsMaxAprBps" } ] } @@ -375,29 +645,40 @@ }, { "name": "LastUpdate", - "docs": ["Last update state"], + "docs": [ + "Last update state" + ], "type": { "kind": "struct", "fields": [ { "name": "slot", - "docs": ["Last slot when updated"], + "docs": [ + "Last slot when updated" + ], "type": "u64" }, { "name": "stale", - "docs": ["True when marked stale, false when slot updated"], + "docs": [ + "True when marked stale, false when slot updated" + ], "type": "u8" }, { "name": "price_status", - "docs": ["Status of the prices used to calculate the last update"], + "docs": [ + "Status of the prices used to calculate the last update" + ], "type": "u8" }, { "name": "placeholder", "type": { - "array": ["u8", 6] + "array": [ + "u8", + 6 + ] } } ] @@ -438,164 +719,477 @@ }, { "name": "debt_reserve", - "docs": ["Mandatory debt reserve for this elevation group"], + "docs": [ + "Mandatory debt reserve for this elevation group" + ], "type": "pubkey" }, { "name": "padding1", "type": { - "array": ["u64", 4] + "array": [ + "u64", + 4 + ] } } ] } }, { - "name": "InitObligationArgs", - "type": { - "kind": "struct", - "fields": [ - { - "name": "tag", - "type": "u8" - }, - { - "name": "id", - "type": "u8" - } - ] - } - }, - { - "name": "ObligationCollateral", - "docs": ["Obligation collateral state"], + "name": "BorrowOrder", + "docs": [ + "A borrow order.", + "", + "When the [Obligation::borrow_order] is populated (i.e. non-zeroed) on an Obligation, then the", + "permissionless \"fill\" operations may borrow liquidity to the owner according to this", + "specification." + ], "type": { "kind": "struct", "fields": [ { - "name": "deposit_reserve", - "docs": ["Reserve collateral is deposited to"], + "name": "debt_liquidity_mint", + "docs": [ + "The asset to be borrowed.", + "The reserves used for [Obligation::borrows] *must* all provide exactly this asset." + ], "type": "pubkey" }, { - "name": "deposited_amount", - "docs": ["Amount of collateral deposited"], + "name": "remaining_debt_amount", + "docs": [ + "The amount of debt that still needs to be filled, in lamports." + ], "type": "u64" }, { - "name": "market_value_sf", + "name": "filled_debt_destination", "docs": [ - "Collateral market value in quote currency (scaled fraction)" + "The token account owned by the [Obligation::owner] and holding [Self::debt_liquidity_mint],", + "where the filled funds should be transferred to." ], - "type": "u128" + "type": "pubkey" }, { - "name": "borrowed_amount_against_this_collateral_in_elevation_group", + "name": "min_debt_term_seconds", "docs": [ - "Debt amount (lamport) taken against this collateral.", - "(only meaningful if this obligation is part of an elevation group, otherwise 0)", - "This is only indicative of the debt computed on the last refresh obligation.", - "If the obligation have multiple collateral this value is the same for all of them." + "The minimum allowed debt term that the obligation owner agrees to.", + "The reserves used to fill this order *cannot* define their debt term *lower* than this.", + "", + "If zeroed, then only open-term reserves may be used." ], "type": "u64" }, { - "name": "padding", - "type": { - "array": ["u64", 9] - } - } - ] - } - }, - { - "name": "ObligationLiquidity", - "docs": ["Obligation liquidity state"], - "type": { - "kind": "struct", - "fields": [ + "name": "fillable_until_timestamp", + "docs": [ + "The time until which the borrow order can still be filled." + ], + "type": "u64" + }, { - "name": "borrow_reserve", - "docs": ["Reserve liquidity is borrowed from"], - "type": "pubkey" + "name": "placed_at_timestamp", + "docs": [ + "The time at which this order was placed.", + "Currently, this is only a piece of metadata." + ], + "type": "u64" }, { - "name": "cumulative_borrow_rate_bsf", + "name": "last_updated_at_timestamp", "docs": [ - "Borrow rate used for calculating interest (big scaled fraction)" + "The time at which this order was most-recently updated (including: created).", + "Currently, this is only a piece of metadata." ], - "type": { - "defined": { - "name": "BigFractionBytes" - } - } + "type": "u64" }, { - "name": "padding", + "name": "requested_debt_amount", + "docs": [ + "The amount of debt that was originally requested when this order was most-recently updated.", + "In other words: this field holds a value of [Self::remaining_debt_amount] captured at", + "[Self::last_updated_at_timestamp].", + "Currently, this is only a piece of metadata." + ], "type": "u64" }, { - "name": "borrowed_amount_sf", + "name": "max_borrow_rate_bps", "docs": [ - "Amount of liquidity borrowed plus interest (scaled fraction)" + "The maximum borrow rate that the obligation owner agrees to.", + "The reserves used for [Obligation::borrows] *cannot* define their maximum borrow rate", + "*higher* than this." ], - "type": "u128" + "type": "u32" }, { - "name": "market_value_sf", + "name": "active", "docs": [ - "Liquidity market value in quote currency (scaled fraction)" + "Whether the [Self::remaining_debt_amount] is non-zero.", + "", + "This field is *not* used by smart contract logic (which prefers to treat the above", + "[Self::remaining_debt_amount]-based definition as the single source of truth). However, it", + "is useful for off-chain bots (order-searchers) to efficiently list (i.e. `memcmp` filter)", + "just the obligations that have active borrow orders." ], - "type": "u128" + "type": "u8" }, { - "name": "borrow_factor_adjusted_market_value_sf", + "name": "enable_auto_rollover_on_filled_borrows", "docs": [ - "Risk adjusted liquidity market value in quote currency - DEBUG ONLY - use market_value instead" + "When `1`, all [Obligation::borrows] that get filled by this order will have their", + "[FixedTermBorrowRolloverConfig::auto_rollover_enabled] flag set.", + "", + "Additionally, their rollover customizations:", + "- will exactly match this order's constraints regarding [Self::min_debt_term_seconds] and", + "[Self::max_borrow_rate_bps];", + "- will use the [FixedTermBorrowRolloverConfig::open_term_allowed] fallback.", + "", + "See [BorrowOrder::get_rollover_config_for_filled_borrow()].", + "", + "Clarification note: when `0`, this setting has no effect on any borrow (i.e. if an existing", + "borrow was independently marked for auto-rollover, it will *not* be unmarked when filled by", + "this order).", + "", + "Feature flag note: when [LendingMarket::obligation_borrow_rollover_configuration_enabled] is", + "disabled, this setting has no effect on any borrow (i.e. the fill will be successful, but", + "the borrow will not be marked for auto-rollover." ], - "type": "u128" + "type": "u8" }, { - "name": "borrowed_amount_outside_elevation_groups", + "name": "padding1", "docs": [ - "Amount of liquidity borrowed outside of an elevation group" + "Alignment padding." ], - "type": "u64" + "type": { + "array": [ + "u8", + 2 + ] + } }, { - "name": "padding2", + "name": "end_padding", + "docs": [ + "End padding." + ], "type": { - "array": ["u64", 7] + "array": [ + "u64", + 5 + ] } } ] } }, { - "name": "ObligationOrder", - "docs": ["A single obligation order.", "See [Obligation::orders]."], + "name": "FixedTermBorrowRolloverConfig", + "docs": [ + "Settings driving the auto-rollover (or migration) of an [ObligationLiquidity]'s borrow.", + "", + "This covers three flavors:", + "- *fixed-to-fixed*: a fixed-term borrow rolling into another fixed-term reserve,", + "- *fixed-to-open*: a fixed-term borrow rolling into an open-term reserve,", + "- *open-to-fixed*: an open-term borrow migrating into a fixed-term reserve.", + "", + "By its nature (not a special case), the zeroed struct means \"no auto-rollover/migration\"." + ], "type": { "kind": "struct", "fields": [ { - "name": "condition_threshold_sf", - "docs": [ - "A threshold value used by the condition (scaled [Fraction]).", - "The exact meaning depends on the specific [Self::condition_type].", + "name": "auto_rollover_enabled", + "docs": [ + "Whether this *fixed-term* borrow can be permissionlessly prolonged. The funds used to roll", + "over can come:", + "- either from a *fixed-term* reserve (same or a different one):", + "- This can only happen within [LendingMarket::fixed_term_rollover_window_duration_seconds].", + "- The target reserve must meet all the criteria defined in this config (see", + "[Self::max_borrow_rate_bps] and [Self::min_debt_term_seconds]).", + "- Note: not possible when [Self::min_debt_term_seconds] is `0` (open-term only).", + "- or from an *open-term* reserve:", + "- This can only happen within [LendingMarket::open_term_rollover_window_duration_seconds].", + "- The user must explicitly set [Self::open_term_allowed] here.", "", - "Examples:", - "- when `condition_type == 2 (UserLtvBelow)`:", - "then a value of `0.455` here means that the order is active only when the obligation's", - "user LTV is less than `0.455` (i.e. < 45.5%).", - "- when `condition_type == 3 (DebtCollPriceRatioAbove)`:", - "assuming the obligation uses BTC collateral for SOL debt, then a value of `491.3` here", - "means that the order is active only when the BTC-SOL price is greater than `491.3` (i.e.", - "> 491.3 SOL per BTC)." + "This setting is not effective when the borrow is currently using an *open-term* reserve." ], - "type": "u128" + "type": "u8" }, { - "name": "opportunity_parameter_sf", + "name": "open_term_allowed", + "docs": [ + "When `1`, then [Self::auto_rollover_enabled] is allowed to roll this borrow over into any", + "open-term reserve.", + "", + "Please note that if such rollover actually happens, then [Self::max_borrow_rate_bps]", + "condition does not apply - technically, it could be evaluated, but open-term reserves", + "typically use float-rate (utilization-driven borrow rate curve) which has very high maximum", + "(when at 100% utilization) that would not meet any practical criteria here." + ], + "type": "u8" + }, + { + "name": "migration_to_fixed_enabled", + "docs": [ + "Whether this *open-term* borrow can be permissionlessly migrated into a fixed-term reserve:", + "- This can happen at any moment (as soon as liquidity becomes available).", + "- The target fixed-term reserve must meet all the criteria defined in this config (see", + "[Self::max_borrow_rate_bps] and [Self::min_debt_term_seconds]).", + "", + "This setting is not effective when the borrow is currently using a *fixed-term* reserve.", + "", + "Cannot be enabled when [Self::min_debt_term_seconds] is `0` (open-term only), because", + "migrating into a fixed-term reserve contradicts the open-term-only intent." + ], + "type": "u8" + }, + { + "name": "alignment_padding", + "docs": [ + "Internal alignment padding (free to reuse)." + ], + "type": { + "array": [ + "u8", + 1 + ] + } + }, + { + "name": "max_borrow_rate_bps", + "docs": [ + "A maximum allowed borrow rate of a reserve that can be used for a rollover/migration.", + "", + "Note: this must be set (i.e. non-zero) when enabling any rollover/migration flavor, but is", + "of course not effective when rollover/migration is not enabled." + ], + "type": "u32" + }, + { + "name": "min_debt_term_seconds", + "docs": [ + "A minimum debt term (in seconds) of a fixed-term reserve that can be used for a", + "rollover/migration.", + "", + "When `0`, the owner only accepts open-term reserves as rollover targets \u2014 i.e. rolling over", + "(or migrating) into a fixed-term reserve is not allowed. This is consistent with the", + "semantics of [BorrowOrder::min_debt_term_seconds].", + "", + "This means that `0` is incompatible with [Self::migration_to_fixed_enabled] (which requires", + "a fixed-term target) \u2014 this combination is rejected at configuration time." + ], + "type": "u64" + } + ] + } + }, + { + "name": "InitObligationArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "tag", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + } + ] + } + }, + { + "name": "ObligationCollateral", + "docs": [ + "Obligation collateral state" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "deposit_reserve", + "docs": [ + "Reserve collateral is deposited to" + ], + "type": "pubkey" + }, + { + "name": "deposited_amount", + "docs": [ + "Amount of collateral deposited" + ], + "type": "u64" + }, + { + "name": "market_value_sf", + "docs": [ + "Collateral market value in quote currency (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "borrowed_amount_against_this_collateral_in_elevation_group", + "docs": [ + "Debt amount (lamport) taken against this collateral.", + "(only meaningful if this obligation is part of an elevation group, otherwise 0)", + "This is only indicative of the debt computed on the last refresh obligation.", + "If the obligation have multiple collateral this value is the same for all of them." + ], + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 9 + ] + } + } + ] + } + }, + { + "name": "ObligationLiquidity", + "docs": [ + "Obligation liquidity state" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "borrow_reserve", + "docs": [ + "Reserve liquidity is borrowed from" + ], + "type": "pubkey" + }, + { + "name": "cumulative_borrow_rate_bsf", + "docs": [ + "Borrow rate used for calculating interest (big scaled fraction)" + ], + "type": { + "defined": { + "name": "BigFractionBytes" + } + } + }, + { + "name": "last_borrowed_at_timestamp", + "docs": [ + "The timestamp at which this debt was taken.", + "", + "Conceptually, every borrow can be interpreted as \"closing the previous loan and starting a", + "new one\" (which would make a plain ` borrowed_at ` an even better name). But in terms of", + "implementation, this fields records when the *last* borrow operation from this reserve", + "happened (i.e. adding debt of the same reserve *does* move this timestamp).", + "", + "Note: this field is *not* only metadata: it is used in the logic, e.g. for enforcing the", + "fixed-term borrows (i.e. those induced by [ReserveConfig::debt_term_seconds])." + ], + "type": "u64" + }, + { + "name": "borrowed_amount_sf", + "docs": [ + "Amount of liquidity borrowed plus interest (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "market_value_sf", + "docs": [ + "Liquidity market value in quote currency (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "borrow_factor_adjusted_market_value_sf", + "docs": [ + "Risk adjusted liquidity market value in quote currency - DEBUG ONLY - use market_value instead" + ], + "type": "u128" + }, + { + "name": "borrowed_amount_outside_elevation_groups", + "docs": [ + "Amount of liquidity borrowed outside of an elevation group" + ], + "type": "u64" + }, + { + "name": "fixed_term_borrow_rollover_config", + "docs": [ + "The user's auto-rollover/migration opt-ins. Some settings are effective only for fixed-term", + "borrows, while others only for open-term borrows - see individual field docs." + ], + "type": { + "defined": { + "name": "FixedTermBorrowRolloverConfig" + } + } + }, + { + "name": "borrowed_amount_at_expiration", + "docs": [ + "An amount of liquidity that was borrowed when this fixed-term borrow expired (i.e. zeroed if", + "this borrow is not fixed-term, or if it did not yet expire).", + "", + "Needed to honor the [LendingMarket::term_based_full_liquidation_duration_secs].", + "", + "This value is captured by [Self::capture_borrowed_amount_at_expiration] during obligation's", + "refresh - please see the method's docs for gotchas.", + "", + "Note on precision: we use a `u64` field, since the remaining space within this struct is", + "rather scarce, and we do not need sub-lamport precision for the liquidation throttling rate." + ], + "type": "u64" + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 4 + ] + } + } + ] + } + }, + { + "name": "ObligationOrder", + "docs": [ + "A single obligation order.", + "See [Obligation::obligation_orders]." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "condition_threshold_sf", + "docs": [ + "A threshold value used by the condition (scaled [Fraction]).", + "The exact meaning depends on the specific [Self::condition_type].", + "", + "Examples:", + "- when `condition_type == 2 (UserLtvBelow)`:", + "then a value of `0.455` here means that the order is active only when the obligation's", + "user LTV is less than `0.455` (i.e. < 45.5%).", + "- when `condition_type == 3 (DebtCollPriceRatioAbove)`:", + "assuming the obligation uses BTC collateral for SOL debt, then a value of `491.3` here", + "means that the order is active only when the BTC-SOL price is greater than `491.3` (i.e.", + "> 491.3 SOL per BTC)." + ], + "type": "u128" + }, + { + "name": "opportunity_parameter_sf", "docs": [ "A configuration parameter used by the opportunity (scaled [Fraction]).", "The exact meaning depends on the specific [Self::opportunity_type].", @@ -669,12 +1263,15 @@ { "name": "padding1", "docs": [ - "Internal padding.", + "Alignment padding.", "The fields above take up 2+2+1+1 bytes = 48 bits, which means we need 80 bits = 10 bytes to", "align with `u128`s." ], "type": { - "array": ["u8", 10] + "array": [ + "u8", + 10 + ] } }, { @@ -684,25 +1281,44 @@ "The total size of a single instance is 8*u128 = 128 bytes." ], "type": { - "array": ["u128", 5] + "array": [ + "u128", + 5 + ] } } ] } }, { - "name": "AssetTier", + "name": "UpdateObligationConfigMode", + "docs": [ + "A discriminator of a user-configurable piece of [Obligation].", + "", + "Implementation note: due to TS-side codegen quirks (and a \"convention\" currently seen e.g.", + "within reserve and market update operations), this is not a true Rust enum. The new value of", + "a config item is provided in a separate handler argument (borsh-serialized), and its expected", + "type is defined by each discriminator here. Additionally, each update mode acts on a specific", + "[ObligationConfigUpdateSubject] (e.g. the auto-rollover of fixed-term borrows is configured on", + "a per-borrow basis), which is also specified by separate handler arguments." + ], "type": { "kind": "enum", "variants": [ { - "name": "Regular" + "name": "FixedTermRolloverEnabled" + }, + { + "name": "FixedTermRolloverMaxBorrowRateBps" }, { - "name": "IsolatedCollateral" + "name": "FixedTermRolloverMinDebtTermSeconds" }, { - "name": "IsolatedDebt" + "name": "FixedTermRolloverOpenTermAllowed" + }, + { + "name": "MigrationToFixedEnabled" } ] } @@ -715,13 +1331,19 @@ { "name": "value", "type": { - "array": ["u64", 4] + "array": [ + "u64", + 4 + ] } }, { "name": "padding", "type": { - "array": ["u64", 2] + "array": [ + "u64", + 2 + ] } } ] @@ -729,7 +1351,9 @@ }, { "name": "FeeCalculation", - "docs": ["Calculate fees exlusive or inclusive of an amount"], + "docs": [ + "Calculate fees exlusive or inclusive of an amount" + ], "type": { "kind": "enum", "variants": [ @@ -744,35 +1368,49 @@ }, { "name": "ReserveCollateral", - "docs": ["Reserve collateral"], + "docs": [ + "Reserve collateral" + ], "type": { "kind": "struct", "fields": [ { "name": "mint_pubkey", - "docs": ["Reserve collateral mint address"], + "docs": [ + "Reserve collateral mint address" + ], "type": "pubkey" }, { "name": "mint_total_supply", - "docs": ["Reserve collateral mint supply, used for exchange rate"], + "docs": [ + "Reserve collateral mint supply, used for exchange rate" + ], "type": "u64" }, { "name": "supply_vault", - "docs": ["Reserve collateral supply address"], + "docs": [ + "Reserve collateral supply address" + ], "type": "pubkey" }, { "name": "padding1", "type": { - "array": ["u128", 32] + "array": [ + "u128", + 32 + ] } }, { "name": "padding2", "type": { - "array": ["u128", 32] + "array": [ + "u128", + 32 + ] } } ] @@ -780,17 +1418,21 @@ }, { "name": "ReserveConfig", - "docs": ["Reserve configuration values"], + "docs": [ + "Reserve configuration values" + ], "type": { "kind": "struct", "fields": [ { "name": "status", - "docs": ["Status of the reserve Active/Obsolete/Hidden"], + "docs": [ + "Status of the reserve Active/Obsolete/Hidden" + ], "type": "u8" }, { - "name": "asset_tier", + "name": "padding_deprecated_asset_tier", "docs": [ "Asset tier -> 0 - regular (collateral & debt), 1 - isolated collateral, 2 - isolated debt" ], @@ -798,7 +1440,9 @@ }, { "name": "host_fixed_interest_rate_bps", - "docs": ["Flat rate that goes to the host"], + "docs": [ + "Flat rate that goes to the host" + ], "type": "u16" }, { @@ -817,11 +1461,35 @@ ], "type": "u8" }, + { + "name": "early_repay_remaining_interest_pct", + "docs": [ + "The percentage of remaining interest over the debt term that is charged as early repay penalty.", + "Only meaningful when `debt_term_seconds > 0`." + ], + "type": "u8" + }, + { + "name": "emergency_mode", + "docs": [ + "Whether the reserve is in emergency mode.", + "Blocks most user operations involving this reserve, similar to [LendingMarket::emergency_mode]", + "but scoped to a single reserve. Also cascades to obligations using this reserve as", + "collateral or debt, blocking borrows and withdrawals on other reserves but still", + "allowing repays and deposits." + ], + "type": "u8" + }, { "name": "reserved1", - "docs": ["Past reserved space - feel free to reuse."], + "docs": [ + "Past reserved space - feel free to reuse." + ], "type": { - "array": ["u8", 6] + "array": [ + "u8", + 4 + ] } }, { @@ -911,7 +1579,9 @@ }, { "name": "borrow_rate_curve", - "docs": ["Borrow rate curve based on utilization"], + "docs": [ + "Borrow rate curve based on utilization" + ], "type": { "defined": { "name": "BorrowRateCurve" @@ -920,7 +1590,9 @@ }, { "name": "borrow_factor_pct", - "docs": ["Borrow factor in percentage - used for risk adjustment"], + "docs": [ + "Borrow factor in percentage - used for risk adjustment" + ], "type": "u64" }, { @@ -939,7 +1611,9 @@ }, { "name": "token_info", - "docs": ["Token id from TokenInfos struct"], + "docs": [ + "Token id from TokenInfos struct" + ], "type": { "defined": { "name": "TokenInfo" @@ -948,7 +1622,9 @@ }, { "name": "deposit_withdrawal_cap", - "docs": ["Deposit withdrawal caps - deposit & redeem"], + "docs": [ + "Deposit withdrawal caps - deposit & redeem" + ], "type": { "defined": { "name": "WithdrawalCaps" @@ -957,7 +1633,9 @@ }, { "name": "debt_withdrawal_cap", - "docs": ["Debt withdrawal caps - borrow & repay"], + "docs": [ + "Debt withdrawal caps - borrow & repay" + ], "type": { "defined": { "name": "WithdrawalCaps" @@ -967,7 +1645,10 @@ { "name": "elevation_groups", "type": { - "array": ["u8", 20] + "array": [ + "u8", + 20 + ] } }, { @@ -987,8 +1668,7 @@ "Whether this reserve should be subject to auto-deleveraging after deposit or borrow limit is", "crossed.", "Besides this flag, the lending market's flag also needs to be enabled (logical `AND`).", - "**NOTE:** the manual \"target LTV\" deleveraging (enabled by the risk council for individual", - "obligations) is NOT affected by this flag." + "**NOTE:** the manual \"target LTV\" deleveraging is NOT affected by this flag." ], "type": "u8" }, @@ -1021,7 +1701,10 @@ "- 0 to disable borrows in this elevation group (expected value for the debt asset)" ], "type": { - "array": ["u64", 32] + "array": [ + "u64", + 32 + ] } }, { @@ -1031,6 +1714,53 @@ "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." ], "type": "u64" + }, + { + "name": "debt_maturity_timestamp", + "docs": [ + "The timestamp at which all [Obligation::borrows] using this reserve become liquidatable", + "(on the same terms as reserve-wide deleveraging).", + "Inactive when zeroed (i.e. debt never matures).", + "", + "Note: this feature is independent of [Self::debt_term_seconds] - the liquidation mechanism", + "is based directly on the timestamp defined here, on Reserve's level." + ], + "type": "u64" + }, + { + "name": "debt_term_seconds", + "docs": [ + "The duration after which any debt coming from this Reserve must be repaid.", + "Inactive when zeroed (i.e. funds can be borrowed indefinitely).", + "", + "Note: this feature is independent of [Self::debt_maturity_timestamp] - the liquidation", + "mechanism is based on the [ObligationLiquidity::last_borrowed_at_timestamp]." + ], + "type": "u64" + }, + { + "name": "rewards_amount_per_slot", + "docs": [ + "Rewards distributed per slot to depositors. Drained from", + "[ReserveLiquidity::rewards_amount_available] into", + "[ReserveLiquidity::total_available_amount] at each refresh, capped by the", + "market-level [LendingMarket::reserve_rewards_max_apr_bps]. `0` disables.", + "", + "**Note:** because rewards inflate `total_available_amount`, a non-zero RPS on a", + "reserve with [Self::autodeleverage_enabled] and a finite [Self::deposit_limit]", + "will eventually cross the cap and arm the autodeleverage countdown. Size", + "`deposit_limit` and RPS together." + ], + "type": "u64" + }, + { + "name": "permissioned_ops", + "docs": [ + "Bitmask of [PermissionedOp]s gated by the parent market's `permissioning_authority`", + "when this reserve is the operation's target. `0` = no operation is restricted at the", + "reserve level. Use [Reserve::get_permissioned_ops] for a typed view." + ], + "type": "u64" } ] } @@ -1083,9 +1813,14 @@ }, { "name": "padding", - "docs": ["Used for allignment"], + "docs": [ + "Used for allignment" + ], "type": { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } } ] @@ -1093,33 +1828,49 @@ }, { "name": "ReserveLiquidity", - "docs": ["Reserve liquidity"], + "docs": [ + "Reserve liquidity" + ], "type": { "kind": "struct", "fields": [ { "name": "mint_pubkey", - "docs": ["Reserve liquidity mint address"], + "docs": [ + "Reserve liquidity mint address" + ], "type": "pubkey" }, { "name": "supply_vault", - "docs": ["Reserve liquidity supply address"], + "docs": [ + "Reserve liquidity supply address" + ], "type": "pubkey" }, { "name": "fee_vault", - "docs": ["Reserve liquidity fee collection address"], + "docs": [ + "Reserve liquidity fee collection address" + ], "type": "pubkey" }, { - "name": "available_amount", - "docs": ["Reserve liquidity available"], + "name": "total_available_amount", + "docs": [ + "Total reserve liquidity available.", + "", + "Note: not all of this liquidity can be freely used for any purpose. Production code should", + "use the specialized getters - see e.g. [Reserve::total_available_liquidity_amount()],", + "[Reserve::freely_available_liquidity_amount()]." + ], "type": "u64" }, { "name": "borrowed_amount_sf", - "docs": ["Reserve liquidity borrowed (scaled fraction)"], + "docs": [ + "Reserve liquidity borrowed (scaled fraction)" + ], "type": "u128" }, { @@ -1131,12 +1882,16 @@ }, { "name": "market_price_last_updated_ts", - "docs": ["Unix timestamp of the market price (from the oracle)"], + "docs": [ + "Unix timestamp of the market price (from the oracle)" + ], "type": "u64" }, { "name": "mint_decimals", - "docs": ["Reserve liquidity mint decimals"], + "docs": [ + "Reserve liquidity mint decimals" + ], "type": "u64" }, { @@ -1168,12 +1923,16 @@ }, { "name": "accumulated_protocol_fees_sf", - "docs": ["Reserve cumulative protocol fees (scaled fraction)"], + "docs": [ + "Reserve cumulative protocol fees (scaled fraction)" + ], "type": "u128" }, { "name": "accumulated_referrer_fees_sf", - "docs": ["Reserve cumulative referrer fees (scaled fraction)"], + "docs": [ + "Reserve cumulative referrer fees (scaled fraction)" + ], "type": "u128" }, { @@ -1192,19 +1951,40 @@ }, { "name": "token_program", - "docs": ["Token program of the liquidity mint"], + "docs": [ + "Token program of the liquidity mint" + ], "type": "pubkey" }, + { + "name": "rewards_amount_available", + "docs": [ + "Reserve rewards budget remaining for distribution.", + "", + "Tokens are deposited via `topup_reserve_rewards` and increase this counter (without", + "touching [Self::total_available_amount]). On every `refresh_reserve`, up to", + "`rewards_amount_per_slot * slots_elapsed` tokens are moved from this counter into", + "[Self::total_available_amount], inflating the cToken exchange rate, capped by the", + "market-level `reserve_rewards_max_apr_bps` cap." + ], + "type": "u64" + }, { "name": "padding2", "type": { - "array": ["u64", 51] + "array": [ + "u64", + 50 + ] } }, { "name": "padding3", "type": { - "array": ["u128", 32] + "array": [ + "u128", + 32 + ] } } ] @@ -1227,9 +2007,46 @@ ] } }, + { + "name": "WithdrawQueue", + "docs": [ + "A tracker of ticket-based withdrawals." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "queued_collateral_amount", + "docs": [ + "The part of [ReserveLiquidity::total_available_amount] locked for ticketed withdrawals." + ], + "type": "u64" + }, + { + "name": "next_issued_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be issued when enqueueing to withdraw.", + "Note: it is also a number of tickets issued so far." + ], + "type": "u64" + }, + { + "name": "next_withdrawable_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be used for actually transferring the withdrawn", + "liquidity (assuming it is available in the reserve).", + "Note: it is also a number of fully-consumed tickets so far." + ], + "type": "u64" + } + ] + } + }, { "name": "WithdrawalCaps", - "docs": ["Reserve Withdrawal Caps State"], + "docs": [ + "Reserve Withdrawal Caps State" + ], "type": { "kind": "struct", "fields": [ @@ -1259,17 +2076,23 @@ "fields": [ { "name": "lower", - "docs": ["Lower value of acceptable price"], + "docs": [ + "Lower value of acceptable price" + ], "type": "u64" }, { "name": "upper", - "docs": ["Upper value of acceptable price"], + "docs": [ + "Upper value of acceptable price" + ], "type": "u64" }, { "name": "exp", - "docs": ["Number of decimals of the previously defined values"], + "docs": [ + "Number of decimals of the previously defined values" + ], "type": "u64" } ] @@ -1308,14 +2131,22 @@ "This is the scope_id price chain that results in a price for the token" ], "type": { - "array": ["u16", 4] + "array": [ + "u16", + 4 + ] } }, { "name": "twap_chain", - "docs": ["This is the scope_id price chain for the twap"], + "docs": [ + "This is the scope_id price chain for the twap" + ], "type": { - "array": ["u16", 4] + "array": [ + "u16", + 4 + ] } } ] @@ -1347,14 +2178,21 @@ "fields": [ { "name": "name", - "docs": ["UTF-8 encoded name of the token (null-terminated)"], + "docs": [ + "UTF-8 encoded name of the token (null-terminated)" + ], "type": { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } }, { "name": "heuristic", - "docs": ["Heuristics limits of acceptable price"], + "docs": [ + "Heuristics limits of acceptable price" + ], "type": { "defined": { "name": "PriceHeuristic" @@ -1363,7 +2201,9 @@ }, { "name": "max_twap_divergence_bps", - "docs": ["Max divergence between twap and price in bps"], + "docs": [ + "Max divergence between twap and price in bps" + ], "type": "u64" }, { @@ -1376,7 +2216,9 @@ }, { "name": "scope_configuration", - "docs": ["Scope price configuration"], + "docs": [ + "Scope price configuration" + ], "type": { "defined": { "name": "ScopeConfiguration" @@ -1385,7 +2227,9 @@ }, { "name": "switchboard_configuration", - "docs": ["Switchboard configuration"], + "docs": [ + "Switchboard configuration" + ], "type": { "defined": { "name": "SwitchboardConfiguration" @@ -1394,7 +2238,9 @@ }, { "name": "pyth_configuration", - "docs": ["Pyth configuration"], + "docs": [ + "Pyth configuration" + ], "type": { "defined": { "name": "PythConfiguration" @@ -1408,18 +2254,49 @@ { "name": "reserved", "type": { - "array": ["u8", 7] + "array": [ + "u8", + 7 + ] } }, { "name": "padding", "type": { - "array": ["u64", 19] + "array": [ + "u64", + 19 + ] } } ] } }, + { + "name": "ProgressCallbackType", + "docs": [ + "A callback to be notified when the ticket is being processed.", + "", + "## Why an enum?", + "", + "Only reliable programs may be used for callbacks (since any error or panic returned from a CPI", + "aborts an entire transaction, which would stall the queue progress). Hence, we need a whitelist,", + "and the simplest initial implementation is a hardcoded enum. If we want to be able to add new", + "whitelist items without SC updates, we can implement such support using a special enum value", + "(e.g. `SPECIFIED_BY_PDA = 255`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "None" + }, + { + "name": "KlendQueueAccountingHandlerOnKvault" + } + ] + } + }, { "name": "BorrowRateCurve", "type": { @@ -1481,25 +2358,37 @@ { "name": "padding0", "type": { - "array": ["u8", 7] + "array": [ + "u8", + 7 + ] } }, { "name": "rewards_tally_scaled", "type": { - "array": ["u128", 10] + "array": [ + "u128", + 10 + ] } }, { "name": "rewards_issued_unclaimed", "type": { - "array": ["u64", 10] + "array": [ + "u64", + 10 + ] } }, { "name": "last_claim_ts", "type": { - "array": ["u64", 10] + "array": [ + "u64", + 10 + ] } }, { @@ -1537,7 +2426,10 @@ { "name": "padding1", "type": { - "array": ["u64", 50] + "array": [ + "u64", + 50 + ] } } ] @@ -1550,7 +2442,9 @@ "fields": [ { "name": "global_admin", - "docs": ["Global admin of the program"], + "docs": [ + "Global admin of the program" + ], "type": "pubkey" }, { @@ -1569,9 +2463,14 @@ }, { "name": "padding", - "docs": ["Padding to make the struct size 1024 bytes"], + "docs": [ + "Padding to make the struct size 1024 bytes" + ], "type": { - "array": ["u8", 928] + "array": [ + "u8", + 928 + ] } } ] @@ -1584,17 +2483,23 @@ "fields": [ { "name": "version", - "docs": ["Version of lending market"], + "docs": [ + "Version of lending market" + ], "type": "u64" }, { "name": "bump_seed", - "docs": ["Bump seed for derived authority address"], + "docs": [ + "Bump seed for derived authority address" + ], "type": "u64" }, { "name": "lending_market_owner", - "docs": ["Owner authority which can add new reserves"], + "docs": [ + "Owner authority which can add new reserves" + ], "type": "pubkey" }, { @@ -1611,7 +2516,10 @@ "e.g. \"USD\" null padded (`*b\"USD\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\"`) or a SPL token mint pubkey" ], "type": { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } }, { @@ -1665,13 +2573,16 @@ { "name": "min_full_liquidation_value_threshold", "docs": [ - "Minimum liquidation value threshold triggering full liquidation for an obligation" + "Minimum liquidation value threshold triggering full liquidation for an obligation, in full", + "units of the quote currency (e.g. `2` means \"$2\", not \"2 lamports of USDC\")." ], "type": "u64" }, { "name": "max_liquidatable_debt_market_value_at_once", - "docs": ["Max allowed liquidation value in one ix call"], + "docs": [ + "Max allowed liquidation value in one ix call" + ], "type": "u64" }, { @@ -1680,7 +2591,10 @@ "[DEPRECATED] Global maximum unhealthy borrow value allowed for any obligation" ], "type": { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } }, { @@ -1691,9 +2605,9 @@ "type": "u64" }, { - "name": "risk_council", + "name": "emergency_council", "docs": [ - "The address of the risk council, in charge of making parameter and risk decisions on behalf of the protocol" + "The address of the emergency council, in charge of taking emergency actions on the market (e.g., enabling emergency mode)" ], "type": "pubkey" }, @@ -1703,7 +2617,10 @@ "[DEPRECATED] Reward points multiplier per obligation type" ], "type": { - "array": ["u8", 8] + "array": [ + "u8", + 8 + ] } }, { @@ -1725,7 +2642,10 @@ { "name": "elevation_group_padding", "type": { - "array": ["u64", 90] + "array": [ + "u64", + 90 + ] } }, { @@ -1744,9 +2664,14 @@ }, { "name": "name", - "docs": ["Market name, zero-padded."], + "docs": [ + "Market name, zero-padded." + ], "type": { - "array": ["u8", 32] + "array": [ + "u8", + 32 + ] } }, { @@ -1760,7 +2685,7 @@ "name": "individual_autodeleverage_margin_call_period_secs", "docs": [ "Time (in seconds) that must pass before liquidation is allowed on an obligation that has", - "been individually marked for auto-deleveraging (by the risk council)." + "been individually marked for auto-deleveraging." ], "type": "u64" }, @@ -1781,7 +2706,9 @@ }, { "name": "immutable", - "docs": ["Whether the lending market is set as immutable."], + "docs": [ + "Whether the lending market is set as immutable." + ], "type": "u8" }, { @@ -1804,23 +2731,211 @@ ], "type": "u8" }, + { + "name": "mature_reserve_debt_liquidation_enabled", + "docs": [ + "Whether the debts that reached their reserve's [ReserveConfig::debt_maturity_timestamp] can", + "be liquidated." + ], + "type": "u8" + }, + { + "name": "obligation_borrow_debt_term_liquidation_enabled", + "docs": [ + "Whether the [Obligation::borrows] that reached their [ReserveConfig::debt_term_seconds] can", + "be liquidated." + ], + "type": "u8" + }, + { + "name": "borrow_order_creation_enabled", + "docs": [ + "Whether new borrow orders can be created.", + "Note: updating or cancelling existing orders is *not* affected by this flag." + ], + "type": "u8" + }, + { + "name": "borrow_order_execution_enabled", + "docs": [ + "Whether the existing borrow orders can be filled." + ], + "type": "u8" + }, + { + "name": "proposer_authority", + "docs": [ + "Authority that can propose creating of new reserves but cannot enable them." + ], + "type": "pubkey" + }, + { + "name": "min_borrow_order_fill_value", + "docs": [ + "Minimum value that can be filled in a single `fill_borrow_order()` call, in full units of", + "the quote currency (e.g. `2` means \"$2\", not \"2 lamports of USDC\")." + ], + "type": "u64" + }, + { + "name": "withdraw_ticket_issuance_enabled", + "docs": [ + "Whether any new withdraw tickets can be issued (i.e. whether new requests can enter the", + "withdraw queue)." + ], + "type": "u8" + }, + { + "name": "withdraw_ticket_redemption_enabled", + "docs": [ + "Whether the existing withdraw tickets can be redeemed (i.e. whether the tickets can be used", + "to transfer accumulated pending liquidity to destination accounts)." + ], + "type": "u8" + }, + { + "name": "obligation_borrow_rollover_configuration_enabled", + "docs": [ + "Whether the owners can enable the borrow rollover/migration on their obligations.", + "", + "*Note 1:* the actual execution of (different kinds of) rollovers are enabled/disabled by:", + "- [Self::fixed_term_rollover_window_duration_seconds],", + "- [Self::open_term_rollover_window_duration_seconds],", + "- [Self::obligation_borrow_migration_to_fixed_execution_enabled].", + "", + "*Note 2:* when this configuration is disabled, the obligation owners can still disable their", + "rollover (i.e. set the obligation's flags to zeroes)." + ], + "type": "u8" + }, + { + "name": "obligation_borrow_migration_to_fixed_execution_enabled", + "docs": [ + "Whether the actual execution of a \"migration to fixed\" rollover flavor is allowed.", + "", + "See [FixedTermBorrowRolloverConfig::migration_to_fixed_enabled]." + ], + "type": "u8" + }, + { + "name": "withdraw_ticket_cancellation_enabled", + "docs": [ + "Whether the ticket owners can cancel their withdraw tickets (i.e. recover ctokens from the", + "queued collateral vault back to their wallet)." + ], + "type": "u8" + }, { "name": "padding2", "type": { - "array": ["u8", 4] + "array": [ + "u8", + 1 + ] } }, { - "name": "proposer_authority", + "name": "reserve_rewards_max_apr_bps", + "docs": [ + "Maximum APR (in basis points; `FULL_BPS = 10_000` = 100%) at which reserves on this market", + "may distribute their `rewards_amount_per_slot`. `0` disables rewards on this market", + "entirely (`topup_reserve_rewards` is rejected). Bounded by `FULL_BPS` (100% APR) when set.", + "See [ReserveConfig::rewards_amount_per_slot] for the depositor-cap interaction." + ], + "type": "u16" + }, + { + "name": "min_withdraw_queued_liquidity_value", + "docs": [ + "Minimum value that can be withdrawn in a single `withdraw_queued_liquidity()` call, in full", + "units of the quote currency (e.g. `2` means \"$2\", not \"2 lamports of USDC\")." + ], + "type": "u64" + }, + { + "name": "fixed_term_rollover_window_duration_seconds", + "docs": [ + "A configurable time window (right before the end of a fixed debt term) during which an", + "auto-rollover into another *fixed* rate/term can happen.", + "", + "When zeroed, this rollover mode is effectively disabled.", + "Can only be enabled when [Self::min_partial_rollover_value] is configured.", + "", + "See [FixedTermBorrowRolloverConfig]." + ], + "type": "u64" + }, + { + "name": "open_term_rollover_window_duration_seconds", + "docs": [ + "A configurable time window (right before the end of a fixed debt term) during which an", + "auto-rollover into a *variable* (indefinite) rate/term can happen.", + "", + "When zeroed, this rollover mode is effectively disabled.", + "Can only be enabled when [Self::min_partial_rollover_value] is configured.", + "", + "This will typically be shorter than [Self::fixed_term_rollover_window_duration_seconds],", + "acting as a fallback if a fixed reserve liquidity remains unavailable for considerable time." + ], + "type": "u64" + }, + { + "name": "min_partial_rollover_value", + "docs": [ + "Minimum dollar value for a partial rollover into a different reserve.", + "When the achievable rollover amount is below this threshold (and it's not a full rollover),", + "the rollover is rejected.", + "", + "In full units of the quote currency (e.g. `2` means \"$2\")." + ], + "type": "u64" + }, + { + "name": "term_based_full_liquidation_duration_secs", + "docs": [ + "The time that must pass before an entire expired debt becomes liquidatable.", + "", + "For example:", + "Let's assume this duration is configured as 100 seconds; then:", + "- right after fixed-term debt expiration, effectively no debt can be liquidated.", + "- 30 seconds after expiration, we allow to 30% of the expired debt to be liquidated", + "- to be specific: at this point in time, we \"protect\" from liquidation 70% of the", + "[ObligationLiquidity::borrowed_amount_at_expiration] (regardless of how much interest", + "was accrued or how much debt was repaid while expired).", + "- 100 seconds after expiration we allow the entire debt to be liquidated.", + "", + "Only effective when [Self::obligation_borrow_debt_term_liquidation_enabled].", + "", + "Motivation note: this throttling feature gives an opportunity to execute a configured", + "auto-rollover (after a partial liquidation brings the debt size down so that there is enough", + "available liquidity in some compatible reserve).", + "", + "When zeroed, an entire expired debt can be liquidated right after expiration (i.e. no", + "throttling)." + ], + "type": "u64" + }, + { + "name": "permissioning_authority", + "docs": [ + "If not NULL, operations encoded in permissioned_ops require a signature from this authority" + ], + "type": "pubkey" + }, + { + "name": "permissioned_ops", "docs": [ - "Authority that can propose creating of new reserves but cannot enable them." + "Bitmap of operations that require permissioning authority signature" ], - "type": "pubkey" + "type": "u64" }, { "name": "padding1", "type": { - "array": ["u64", 165] + "array": [ + "u64", + 153 + ] } } ] @@ -1828,13 +2943,17 @@ }, { "name": "Obligation", - "docs": ["Lending market obligation state"], + "docs": [ + "Lending market obligation state" + ], "type": { "kind": "struct", "fields": [ { "name": "tag", - "docs": ["Version of the struct"], + "docs": [ + "Version of the struct" + ], "type": "u64" }, { @@ -1850,12 +2969,16 @@ }, { "name": "lending_market", - "docs": ["Lending market address"], + "docs": [ + "Lending market address" + ], "type": "pubkey" }, { "name": "owner", - "docs": ["Owner authority which can borrow liquidity"], + "docs": [ + "Owner authority which can borrow liquidity" + ], "type": "pubkey" }, { @@ -1883,7 +3006,9 @@ }, { "name": "deposited_value_sf", - "docs": ["Market value of deposits (scaled fraction)"], + "docs": [ + "Market value of deposits (scaled fraction)" + ], "type": "u128" }, { @@ -1931,22 +3056,22 @@ "type": "u128" }, { - "name": "deposits_asset_tiers", - "docs": ["The asset tier of the deposits"], - "type": { - "array": ["u8", 8] - } - }, - { - "name": "borrows_asset_tiers", - "docs": ["The asset tier of the borrows"], + "name": "padding_deprecated_asset_tiers", + "docs": [ + "The asset tier of the deposits" + ], "type": { - "array": ["u8", 5] + "array": [ + "u8", + 13 + ] } }, { "name": "elevation_group", - "docs": ["The elevation group id the obligation opted into."], + "docs": [ + "The elevation group id the obligation opted into." + ], "type": "u8" }, { @@ -1965,18 +3090,22 @@ }, { "name": "referrer", - "docs": ["Wallet address of the referrer"], + "docs": [ + "Wallet address of the referrer" + ], "type": "pubkey" }, { "name": "borrowing_disabled", - "docs": ["Marked = 1 if borrowing disabled, 0 = borrowing enabled"], + "docs": [ + "Marked = 1 if borrowing disabled, 0 = borrowing enabled" + ], "type": "u8" }, { "name": "autodeleverage_target_ltv_pct", "docs": [ - "A target LTV set by the risk council when marking this obligation for deleveraging.", + "A target LTV set by the market owner when marking this obligation for deleveraging.", "Only effective when `deleveraging_margin_call_started_slot != 0`." ], "type": "u8" @@ -1995,10 +3124,20 @@ ], "type": "u8" }, + { + "name": "ownership_transfer_state", + "docs": [ + "State of ownership transfer, see [OwnershipTransferState]" + ], + "type": "u8" + }, { "name": "reserved", "type": { - "array": ["u8", 4] + "array": [ + "u8", + 3 + ] } }, { @@ -2008,15 +3147,15 @@ { "name": "autodeleverage_margin_call_started_timestamp", "docs": [ - "A timestamp at which the risk council most-recently marked this obligation for deleveraging.", + "A timestamp at which the market owner most-recently marked this obligation for deleveraging.", "Zero if not currently subject to deleveraging." ], "type": "u64" }, { - "name": "orders", + "name": "obligation_orders", "docs": [ - "Owner-defined, liquidator-executed orders applicable to this obligation.", + "Owner-defined, permissionlessly-executed repay orders.", "Typical use-cases would be a stop-loss and a take-profit (possibly co-existing)." ], "type": { @@ -2030,10 +3169,33 @@ ] } }, + { + "name": "borrow_order", + "docs": [ + "Owner-defined, permissionlessly-executed borrow order applicable to this obligation.", + "Non-zeroed only on a newly-initialized fixed-rate, fixed-term obligation." + ], + "type": { + "defined": { + "name": "BorrowOrder" + } + } + }, + { + "name": "pending_owner", + "docs": [ + "Pending owner during ownership transfer process.", + "Pubkey::default() means no pending owner (similar to Option::None)" + ], + "type": "pubkey" + }, { "name": "padding3", "type": { - "array": ["u64", 93] + "array": [ + "u64", + 69 + ] } } ] @@ -2065,12 +3227,16 @@ "fields": [ { "name": "referrer", - "docs": ["Pubkey of the referrer/owner"], + "docs": [ + "Pubkey of the referrer/owner" + ], "type": "pubkey" }, { "name": "mint", - "docs": ["Token mint for the account"], + "docs": [ + "Token mint for the account" + ], "type": "pubkey" }, { @@ -2089,13 +3255,18 @@ }, { "name": "bump", - "docs": ["Referrer token state bump, used for address validation"], + "docs": [ + "Referrer token state bump, used for address validation" + ], "type": "u64" }, { "name": "padding", "type": { - "array": ["u64", 31] + "array": [ + "u64", + 31 + ] } } ] @@ -2134,7 +3305,9 @@ }, { "name": "bump", - "docs": ["Bump used for validation of account address"], + "docs": [ + "Bump used for validation of account address" + ], "type": "u64" }, { @@ -2146,19 +3319,27 @@ }, { "name": "owner", - "docs": ["User metadata account owner"], + "docs": [ + "User metadata account owner" + ], "type": "pubkey" }, { "name": "padding1", "type": { - "array": ["u64", 51] + "array": [ + "u64", + 51 + ] } }, { "name": "padding2", "type": { - "array": ["u64", 64] + "array": [ + "u64", + 64 + ] } } ] @@ -2171,12 +3352,16 @@ "fields": [ { "name": "version", - "docs": ["Version of the reserve"], + "docs": [ + "Version of the reserve" + ], "type": "u64" }, { "name": "last_update", - "docs": ["Last slot when supply and rates updated"], + "docs": [ + "Last slot when supply and rates updated" + ], "type": { "defined": { "name": "LastUpdate" @@ -2185,7 +3370,9 @@ }, { "name": "lending_market", - "docs": ["Lending market address"], + "docs": [ + "Lending market address" + ], "type": "pubkey" }, { @@ -2198,7 +3385,9 @@ }, { "name": "liquidity", - "docs": ["Reserve liquidity"], + "docs": [ + "Reserve liquidity" + ], "type": { "defined": { "name": "ReserveLiquidity" @@ -2208,12 +3397,17 @@ { "name": "reserve_liquidity_padding", "type": { - "array": ["u64", 150] + "array": [ + "u64", + 150 + ] } }, { "name": "collateral", - "docs": ["Reserve collateral"], + "docs": [ + "Reserve collateral" + ], "type": { "defined": { "name": "ReserveCollateral" @@ -2223,12 +3417,17 @@ { "name": "reserve_collateral_padding", "type": { - "array": ["u64", 150] + "array": [ + "u64", + 150 + ] } }, { "name": "config", - "docs": ["Reserve configuration values"], + "docs": [ + "Reserve configuration values" + ], "type": { "defined": { "name": "ReserveConfig" @@ -2238,7 +3437,10 @@ { "name": "config_padding", "type": { - "array": ["u64", 116] + "array": [ + "u64", + 112 + ] } }, { @@ -2252,13 +3454,253 @@ "elevation group when this reserve is part of the collaterals." ], "type": { - "array": ["u64", 32] + "array": [ + "u64", + 32 + ] + } + }, + { + "name": "withdraw_queue", + "docs": [ + "The tracker of ticket-based withdrawals." + ], + "type": { + "defined": { + "name": "WithdrawQueue" + } } }, { "name": "padding", "type": { - "array": ["u64", 207] + "array": [ + "u64", + 204 + ] + } + } + ] + } + }, + { + "name": "WithdrawTicket", + "docs": [ + "A finite-lifecycle account representing a specific depositor's place in the withdraw queue of", + "a specific reserve.", + "", + "The lifecycle:", + "1. The depositor holding ctokens wants to withdraw funds from the reserve, and finds out that", + "the required amount is not available (due to high utilization).", + "2. The depositor calls the `enqueue_to_withdraw` handler.", + "3. The handler transfers the depositor's ctokens to the reserve's internal \"pending\" vault.", + "4. The handler initializes a new [WithdrawTicket] account, with the next available sequence", + "number.", + "5. The depositor waits until his ticket is the next expected one for actual withdraw, and until", + "the reserve has enough liquidity.", + "6. Anyone (the depositor or a bot) calls the permissionless `withdraw_queued_liquidity`", + "handler. If the ticket became invalid (e.g. destination account no longer exists), then the", + "depositor can call the `recover_invalid_ticket_collateral` handler instead.", + "7. The handler transfers the liquidity amount according to the current exchange rate.", + "8. The handler closes the ticket account." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "sequence_number", + "docs": [ + "This ticket's place in the queue; the same as used for PDA derivation." + ], + "type": "u64" + }, + { + "name": "owner", + "docs": [ + "The funds' owner (the user who called the `enqueue_to_withdraw` handler)." + ], + "type": "pubkey" + }, + { + "name": "reserve", + "docs": [ + "The reserve to withdraw from." + ], + "type": "pubkey" + }, + { + "name": "user_destination_liquidity_ta", + "docs": [ + "The token account to which the finally-available liquidity should be transferred (by the", + "`withdraw_queued_liquidity` handler)." + ], + "type": "pubkey" + }, + { + "name": "queued_collateral_amount", + "docs": [ + "The amount of collateral still waiting to be withdrawn using this ticket." + ], + "type": "u64" + }, + { + "name": "created_at_timestamp", + "docs": [ + "The timestamp at which the queue was entered.", + "", + "This is currently only a piece of metadata, not used by the logic." + ], + "type": "u64" + }, + { + "name": "invalid", + "docs": [ + "Whether the ticket has been found to be invalid (e.g. the [Self::user_destination_liquidity]", + "has been repurposed) by the `withdraw_queued_liquidity` handler.", + "To be specific: valid = `0`, invalid = `1`.", + "", + "An invalid ticket cannot be made valid again, and can only be passed to the", + "`recover_invalid_ticket_collateral` handler." + ], + "type": "u8" + }, + { + "name": "progress_callback_type", + "docs": [ + "One of the valid [ProgressCallbackType] representations." + ], + "type": "u8" + }, + { + "name": "alignment_padding", + "docs": [ + "Inner padding, for alignment." + ], + "type": { + "array": [ + "u8", + 6 + ] + } + }, + { + "name": "progress_callback_custom_accounts", + "docs": [ + "The (optional) accounts to be used by [Self::progress_callback_type]s." + ], + "type": { + "array": [ + "pubkey", + 2 + ] + } + }, + { + "name": "end_padding", + "docs": [ + "Trailing padding, for future developments." + ], + "type": { + "array": [ + "u64", + 40 + ] + } + } + ] + } + }, + { + "name": "BorrowOrderCancelEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderFullFillEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderPartialFillEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + }, + { + "name": "after", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderPlaceEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "after", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + } + ] + } + }, + { + "name": "BorrowOrderUpdateEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "before", + "type": { + "defined": { + "name": "BorrowOrder" + } + } + }, + { + "name": "after", + "type": { + "defined": { + "name": "BorrowOrder" + } } } ] diff --git a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml index d4a69d21e..7ab89e5f4 100644 --- a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml @@ -1,5 +1,5 @@ protocol: kamino -version: v1.12.6 +version: v1.23.0 account_type: Reserve idl_file_path: idl.json @@ -9,52 +9,595 @@ tags: - defi templates: + # ========================================== + # Reserve + # ========================================== - id: kamino-reserve-state name: Override Reserve Liquidity & Rates - description: Override Kamino Reserve liquidity and interest rate data + description: Override Kamino Reserve liquidity, accrued fees and cached price idl_account_name: Reserve properties: - [ - "liquidity.available_amount", - "liquidity.borrowed_amount_sf", - "liquidity.market_price_sf", - "liquidity.cumulative_borrow_rate_bsf", - ] + - "liquidity.total_available_amount" + - "liquidity.borrowed_amount_sf" + - "liquidity.market_price_sf" + - "liquidity.market_price_last_updated_ts" + - "liquidity.cumulative_borrow_rate_bsf" + - "liquidity.accumulated_protocol_fees_sf" + - "liquidity.accumulated_referrer_fees_sf" + - "liquidity.pending_referrer_fees_sf" + - "last_update.slot" + - "last_update.stale" + - "last_update.price_status" address: type: pubkey + llm_context: | + CRITICAL: market_price_sf is a CACHE. refresh_reserve recomputes it from the configured + oracle, so any transaction that refreshes the reserve overwrites it. Use kamino-scope-price + for a price that survives. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true so the real reserve is forked first + 2. To make the reserve look freshly refreshed, set last_update.stale: 0 and + last_update.slot to the current slot + 3. To drain a reserve, set liquidity.total_available_amount: 0 + + liquidity.cumulative_borrow_rate_bsf is a struct - supply it whole as + {"value": [u64 x 4], "padding": [u64 x 2]}, or set one limb with + liquidity.cumulative_borrow_rate_bsf.value.0 + + EXAMPLE - "reserve has run dry" (forces the withdrawal queue): + liquidity.total_available_amount: 0 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. - id: kamino-reserve-config name: Override Reserve Risk Configuration - description: Override Kamino Reserve risk parameters and liquidation settings + description: Override Kamino Reserve LTV, liquidation thresholds and bonuses idl_account_name: Reserve properties: - [ - "config.loan_to_value_pct", - "config.liquidation_threshold_pct", - "config.min_liquidation_bonus_bps", - "config.max_liquidation_bonus_bps", - ] + - "config.loan_to_value_pct" + - "config.liquidation_threshold_pct" + - "config.min_liquidation_bonus_bps" + - "config.max_liquidation_bonus_bps" + - "config.bad_debt_liquidation_bonus_bps" + - "config.protocol_liquidation_fee_pct" + - "config.borrow_factor_pct" + - "config.min_deleveraging_bonus_bps" + - "config.deleveraging_margin_call_period_secs" + - "config.deleveraging_threshold_decrease_bps_per_day" + - "config.deleveraging_bonus_increase_bps_per_day" address: type: pubkey + llm_context: | + Use this template to make a position liquidatable in a way that survives refresh_obligation, + unlike the Obligation health fields. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true + 2. Lower config.liquidation_threshold_pct below the borrower's current LTV + 3. Keep it >= config.loan_to_value_pct, which gates new borrows + + EXAMPLE - "liquidate anything above 50% LTV": + config.liquidation_threshold_pct: 50 + config.max_liquidation_bonus_bps: 1000 + + - id: kamino-reserve-status + name: Override Reserve Status & Usage Flags + description: Override Kamino Reserve status and usage restrictions + idl_account_name: Reserve + properties: + - "config.status" + - "config.block_ctoken_usage" + - path: config.disable_usage_as_coll_outside_emode + label: Collateral only in e-mode + description: "1 stops this asset being used as collateral outside an elevation group. Example: 1" + - "config.emergency_mode" + - "config.utilization_limit_block_borrowing_above_pct" + - "config.autodeleverage_enabled" + - "config.proposer_authority_locked" + - path: config.elevation_groups + label: Elevation groups + description: "The 20 elevation-group ids this reserve may join; 0 is empty. Example: 1" + address: + type: pubkey + llm_context: | + Use this template to disable a reserve or change its elevation-group membership. + + config.status: 0 = Active, 1 = Obsolete, 2 = Hidden. Marking a reserve Obsolete exercises the + num_of_obsolete_deposit_reserves / num_of_obsolete_borrow_reserves paths on an Obligation. + + config.elevation_groups is a fixed [u8; 20] array - supply all 20 entries, or one slot with + config.elevation_groups.0 + + EXAMPLE - "reserve is deprecated": + config.status: 1 + + - id: kamino-reserve-limits + name: Override Reserve Deposit & Borrow Limits + description: Override Kamino Reserve caps and the withdrawal queue + idl_account_name: Reserve + properties: + - "config.deposit_limit" + - "config.borrow_limit" + - "config.borrow_limit_outside_elevation_group" + - path: config.deposit_withdrawal_cap.config_capacity + label: Deposit cap per interval + description: "Maximum that may be deposited per interval, in the token's smallest unit. Example: -1" + - path: config.deposit_withdrawal_cap.current_total + label: Deposited this interval + description: "Running total deposited in the current interval. Example: 0" + - path: config.deposit_withdrawal_cap.config_interval_length_seconds + label: Deposit cap window + description: "Length of the deposit cap window, in seconds. Example: 86400" + - path: config.deposit_withdrawal_cap.last_interval_start_timestamp + label: Deposit window start + description: "When the current deposit window opened (unix seconds). Example: 1800000000" + - path: config.debt_withdrawal_cap.config_capacity + label: Borrow cap per interval + description: "Maximum that may be borrowed per interval, smallest unit. Example: -1" + - path: config.debt_withdrawal_cap.current_total + label: Borrowed this interval + description: "Running total borrowed in the current interval. Example: 0" + - path: config.debt_withdrawal_cap.config_interval_length_seconds + label: Borrow cap window + description: "Length of the borrow cap window, in seconds. Example: 86400" + - path: config.debt_withdrawal_cap.last_interval_start_timestamp + label: Borrow window start + description: "When the current borrow window opened (unix seconds). Example: 1800000000" + - "liquidity.deposit_limit_crossed_timestamp" + - "liquidity.borrow_limit_crossed_timestamp" + - path: borrowed_amount_outside_elevation_group + label: Borrowed outside e-mode + description: >- + Amount borrowed against this reserve by obligations not in an elevation group, smallest unit. + Example: 0 + - "withdraw_queue.queued_collateral_amount" + - "withdraw_queue.next_issued_ticket_sequence_number" + - "withdraw_queue.next_withdrawable_ticket_sequence_number" + address: + type: pubkey + llm_context: | + Use this template for borrow/deposit caps and for the queued-withdrawal feature. + + HOW TO USE THIS TEMPLATE (queued withdrawals, klend 1.23.0): + 1. Drain the reserve with kamino-reserve-state (liquidity.total_available_amount: 0) + 2. Enable the feature on kamino-lending-market-risk (withdraw_ticket_issuance_enabled: 1) + 3. Set withdraw_queue.next_withdrawable_ticket_sequence_number to serve a ticket + 4. Build the ticket itself with kamino-withdraw-ticket + + Set a config_capacity of -1 to disable a withdrawal cap. + + EXAMPLE - "no new borrows against this reserve": + config.borrow_limit: 0 + + - id: kamino-reserve-fees + name: Override Reserve Fees + description: Override Kamino Reserve origination, flash-loan and protocol fees + idl_account_name: Reserve + properties: + - "config.fees.origination_fee_sf" + - "config.fees.flash_loan_fee_sf" + - "config.host_fixed_interest_rate_bps" + - "config.protocol_take_rate_pct" + - "config.protocol_order_execution_fee_pct" + address: + type: pubkey + llm_context: | + Use this template to remove fee noise from an arbitrage simulation. + + Fees ending in _sf are scaled fractions: a 0.3% flash-loan fee is 0.003 * 2^60. + + EXAMPLE - "free flash loans" so only the swap legs decide profitability: + config.fees.flash_loan_fee_sf: 0 + config.fees.origination_fee_sf: 0 + + - id: kamino-reserve-interest-rate + name: Override Reserve Borrow Rate Curve + description: Override the Kamino Reserve borrow-rate curve + idl_account_name: Reserve + properties: + - "config.borrow_rate_curve" + address: + type: pubkey + llm_context: | + config.borrow_rate_curve is a struct with one field, points, a fixed array of EXACTLY 11 + CurvePoint entries sorted by ascending utilization_rate_bps. Pad the tail by repeating the + final point, which Kamino treats as the end of the curve. + + HOW TO USE THIS TEMPLATE: + 1. Prefer an element path to change one point, e.g. + config.borrow_rate_curve.points.3.borrow_rate_bps + 2. Only supply the whole struct if you are replacing the entire curve + + EXAMPLE - raise the borrow rate at the 4th curve point to 50%: + config.borrow_rate_curve.points.3.borrow_rate_bps: 5000 + + - id: kamino-reserve-oracle + name: Override Reserve Oracle Configuration + description: Override which oracle a Kamino Reserve reads, and its staleness guards + idl_account_name: Reserve + properties: + - "config.token_info.scope_configuration.price_feed" + - "config.token_info.scope_configuration.price_chain" + - "config.token_info.scope_configuration.twap_chain" + - "config.token_info.pyth_configuration.price" + - "config.token_info.switchboard_configuration.price_aggregator" + - path: config.token_info.switchboard_configuration.twap_aggregator + label: Switchboard TWAP feed + description: >- + Switchboard aggregator supplying a TWAP for this token. Example: the aggregator address, or + the default pubkey to disable + - path: config.token_info.max_age_price_seconds + label: Max price age + description: "How old the oracle price may be before Kamino refuses it, in seconds. Example: 600" + - path: config.token_info.max_age_twap_seconds + label: Max TWAP age + description: "How old the TWAP may be before Kamino refuses it, in seconds. Example: 600" + - "config.token_info.max_twap_divergence_bps" + - path: config.token_info.block_price_usage + label: Block price usage + description: >- + 1 stops this token's price being used at all, which freezes borrowing against it. Example: 1 + - "config.token_info.heuristic.lower" + - "config.token_info.heuristic.upper" + - "config.token_info.heuristic.exp" + address: + type: pubkey + llm_context: | + Use this template to change WHICH oracle a reserve reads, rather than the price itself. + + HOW TO USE THIS TEMPLATE: + 1. To drive the price from a Pyth feed you already control, set + config.token_info.pyth_configuration.price to that feed and use the pyth-price-feed-v2 + template to move it - this survives refresh_reserve + 2. To fix a stale-price rejection, raise config.token_info.max_age_price_seconds + 3. To fix a TWAP divergence rejection, raise config.token_info.max_twap_divergence_bps + + price_chain and twap_chain are fixed [u16; 4] arrays - supply all 4, or one entry with + config.token_info.scope_configuration.price_chain.0 (65535 = unused) + + EXAMPLE - "accept prices up to an hour old": + config.token_info.max_age_price_seconds: 3600 + + - id: kamino-reserve-rewards + name: Override Reserve Reward Emissions + description: Override Kamino Reserve reward emissions + idl_account_name: Reserve + properties: + - "config.rewards_amount_per_slot" + - "liquidity.rewards_amount_available" + address: + type: pubkey + llm_context: | + Reserve-level rewards (klend 1.23.0) are separate from Kamino Farms - use the kamino-farms-* + templates for those. + + HOW TO USE THIS TEMPLATE: + 1. Set config.rewards_amount_per_slot to the emission rate (smallest unit per slot, ~2.5 + slots per second) + 2. Raise liquidity.rewards_amount_available too, or emissions stop when the budget empties + 3. Check reserve_rewards_max_apr_bps on kamino-lending-market-risk is not capping you + + EXAMPLE - "emit 1 USDC per second to depositors" (6 decimals, ~2.5 slots/sec): + config.rewards_amount_per_slot: 400000 + liquidity.rewards_amount_available: 1000000000 + + - id: kamino-reserve-debt-term + name: Override Reserve Fixed-Term Debt Settings + description: Override Kamino Reserve fixed-term debt settings + idl_account_name: Reserve + properties: + - "config.debt_term_seconds" + - "config.debt_maturity_timestamp" + - "config.early_repay_remaining_interest_pct" + address: + type: pubkey + llm_context: | + Fixed-term borrowing arrived in klend 1.23.0. A debt_term_seconds of 0 means the reserve uses + open-term (perpetual) loans. + + HOW TO USE THIS TEMPLATE: + 1. Set config.debt_maturity_timestamp to a unix timestamp in the past so outstanding + fixed-term debt matures immediately + 2. Enable mature_reserve_debt_liquidation_enabled on kamino-lending-market-risk, or the + maturity liquidation path stays inactive + EXAMPLE - "this debt matured yesterday": + config.debt_maturity_timestamp: 1799913600 + + - id: kamino-withdraw-ticket + name: Override Withdraw Ticket + description: Override a Kamino queued-withdrawal ticket + idl_account_name: WithdrawTicket + properties: + - "sequence_number" + - "owner" + - "reserve" + - "user_destination_liquidity_ta" + - "queued_collateral_amount" + - "created_at_timestamp" + - "invalid" + - "progress_callback_type" + address: + type: pubkey + llm_context: | + CRITICAL: No live WithdrawTicket existed on mainnet when this template was written. Build one + with surfnet_setAccount rather than expecting to fork one. + + HOW TO USE THIS TEMPLATE: + 1. Set owner and user_destination_liquidity_ta - the destination must be a real token account + for the reserve's liquidity mint + 2. To make the ticket redeemable, set sequence_number at or below the reserve's + withdraw_queue.next_withdrawable_ticket_sequence_number (kamino-reserve-limits) + 3. To test the not-yet-your-turn rejection, set it above + + EXAMPLE - "ticket 7 is next in line, waiting on 500 collateral": + sequence_number: 7 + queued_collateral_amount: 500 + invalid: 0 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + # ========================================== + # Named reserves - addresses pre-filled + # ========================================== + # Kamino reserves are NOT PDAs (see `init_reserve` in the IDL: the reserve account is a plain + # keypair account), so an address cannot be derived from a token mint. The only way to spare a + # user the lookup is to bake in known addresses, the same approach the whirlpool templates take. + # + # These are facts about mainnet as captured on 2026-08-06, verified by decoding each account + # with the bundled IDL: every address below is an active Reserve owned by + # KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD, and every Scope index below was checked to + # produce the reserve's own cached price. Re-verify if Kamino migrates a market. + # + # Only the canonical demo pair is baked in; six near-duplicates implied a "supported set" that + # does not exist. For any other reserve use the generic `kamino-reserve-*` templates and supply + # the address - see their llm_context for how to find one. + + - id: kamino-reserve-main-sol + name: Override SOL Reserve (Main Market) + description: Override the SOL reserve of Kamino's Main Market + idl_account_name: Reserve + properties: + - "config.loan_to_value_pct" + - "config.liquidation_threshold_pct" + - "config.min_liquidation_bonus_bps" + - "config.max_liquidation_bonus_bps" + - path: config.token_info.max_age_price_seconds + label: Max price age + description: "How old the oracle price may be before Kamino refuses it, in seconds. Example: 600" + - "liquidity.market_price_sf" + - "liquidity.total_available_amount" + - "last_update.slot" + - "last_update.stale" + address: + type: pubkey + value: d4A2prbA2whesmvHaL88BH6Ewn5N4bTSU2Ze8P6Bc4Q + llm_context: | + The SOL reserve of Kamino's Main Market, address already filled in - no lookup needed. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true + 2. Lower config.liquidation_threshold_pct below the borrower's LTV - this survives + refresh_obligation, unlike the Obligation's own health fields + 3. To move the price, use kamino-scope-price on account + 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH with prices.3.price.value (SOL is index 3) + + EXAMPLE - "liquidate SOL collateral above 50% LTV": + config.liquidation_threshold_pct: 50 + + persist: true is safe for the config.* fields only. liquidity.* and last_update.* are + rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve. + - id: kamino-reserve-main-usdc + name: Override USDC Reserve (Main Market) + description: Override the USDC reserve of Kamino's Main Market + idl_account_name: Reserve + properties: + - "config.loan_to_value_pct" + - "config.liquidation_threshold_pct" + - "config.min_liquidation_bonus_bps" + - "config.max_liquidation_bonus_bps" + - path: config.token_info.max_age_price_seconds + label: Max price age + description: "How old the oracle price may be before Kamino refuses it, in seconds. Example: 600" + - "liquidity.market_price_sf" + - "liquidity.total_available_amount" + - "last_update.slot" + - "last_update.stale" + address: + type: pubkey + value: D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 + llm_context: | + The USDC reserve of Kamino's Main Market, address already filled in - no lookup needed. + + HOW TO USE THIS TEMPLATE: + 1. Set fetchBeforeUse: true + 2. Lower config.liquidation_threshold_pct below the borrower's LTV - this survives + refresh_obligation, unlike the Obligation's own health fields + 3. To move the price, use kamino-scope-price on account + 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH with prices.13.price.value (USDC is index 13) + + EXAMPLE - "USDC depegs to $0.90": + use kamino-scope-price with prices.13.price.value: 90000000 and prices.13.price.exp: 8 + + persist: true is safe for the config.* fields only. liquidity.* and last_update.* are + rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve. + # ========================================== + # Obligation + # ========================================== - id: kamino-obligation-health name: Override Obligation Health - description: Override Kamino Obligation health metrics for testing liquidation scenarios. An obligation becomes unhealthy (liquidatable) when borrowed_value_sf exceeds unhealthy_borrow_value_sf. Use deposits/borrows arrays to set actual positions. + description: Override Kamino Obligation health metrics + idl_account_name: Obligation + properties: + - "last_update.slot" + - "last_update.stale" + - "deposited_value_sf" + - "borrow_factor_adjusted_debt_value_sf" + - "borrowed_assets_market_value_sf" + - "allowed_borrow_value_sf" + - "unhealthy_borrow_value_sf" + - "lowest_reserve_deposit_liquidation_ltv" + - "lowest_reserve_deposit_max_ltv_pct" + - path: highest_borrow_factor_pct + label: Highest borrow factor + description: "The largest borrow factor across this obligation's debts, as a percent. Example: 100" + - "borrowing_disabled" + - "num_of_obsolete_deposit_reserves" + - "num_of_obsolete_borrow_reserves" + - "autodeleverage_target_ltv_pct" + address: + type: pubkey + llm_context: | + CRITICAL: These are DERIVED values. refresh_obligation recomputes every one of them from the + positions and reserves, and liquidation instructions reject a stale obligation - so a + realistic liquidation transaction discards these overrides. + + TO MAKE A POSITION LIQUIDATABLE DURABLY, use one of these instead: + - kamino-reserve-config: lower config.liquidation_threshold_pct on the deposit reserve + - kamino-scope-price: move the price the reserve reads + + Use this template only for assertions that do not refresh. All *_sf values are scaled + fractions: usd_value * 2^60. + + EXAMPLE - force an unhealthy obligation for a direct state check ($1000 debt vs $500 limit): + borrow_factor_adjusted_debt_value_sf: "1152921504606846976000" + unhealthy_borrow_value_sf: "576460752303423488000" + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-obligation-positions + name: Override Obligation Positions + description: Override the deposits and borrows of a Kamino Obligation + idl_account_name: Obligation + properties: + - "tag" + - "lending_market" + - "owner" + - "referrer" + - "deposits" + - "borrows" + - "has_debt" + - "elevation_group" + address: + type: pubkey + llm_context: | + CRITICAL: Prefer element paths. Supplying a whole array requires it COMPLETE - every field of + every element including padding, with unused slots all-zero and the reserve set to + 11111111111111111111111111111111 + + HOW TO USE THIS TEMPLATE: + 1. Set one position with deposits.0.deposit_reserve and deposits.0.deposited_amount + 2. Set the matching debt with borrows.0.borrow_reserve and borrows.0.borrowed_amount_sf + 3. Set has_debt: 1 whenever any borrow slot is populated + + Array sizes: deposits = 8 slots, borrows = 5 slots. + + EXAMPLE - "10 SOL deposited against the Main Market SOL reserve": + deposits.0.deposit_reserve: d4A2prbA2whesmvHaL88BH6Ewn5N4bTSU2Ze8P6Bc4Q + deposits.0.deposited_amount: 10000000000 + has_debt: 1 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-obligation-orders + name: Override Obligation Orders + description: Override Kamino Obligation stop-loss and take-profit orders idl_account_name: Obligation properties: - [ - "last_update_slot", - "lending_market", - "owner", - "deposits", - "borrows", - "deposited_value_sf", - "borrowed_value_sf", - "allowed_borrow_value_sf", - "unhealthy_borrow_value_sf", - "borrowing_disabled", - "highest_borrow_factor_pct", - "num_of_obsolete_reserves", - ] + - "obligation_orders" + - "autodeleverage_margin_call_started_timestamp" + - "autodeleverage_target_ltv_pct" + address: + type: pubkey + llm_context: | + obligation_orders is a fixed array of EXACTLY 2 entries. Prefer element paths for a single + order. An all-zero entry is an empty slot. + + HOW TO USE THIS TEMPLATE: + 1. Set obligation_orders.0.condition_threshold_sf and obligation_orders.0.condition_type + 2. Enable obligation_order_execution_enabled on kamino-lending-market-risk, or the order + never executes + + EXAMPLE - arm a stop-loss on the first order slot: + obligation_orders.0.condition_threshold_sf: "576460752303423488000" + obligation_orders.0.min_execution_bonus_bps: 100 + + # ========================================== + # LendingMarket + # ========================================== + - id: kamino-lending-market-risk + name: Override Lending Market Risk Controls + description: Override Kamino market-wide switches and liquidation limits + idl_account_name: LendingMarket + properties: + - path: emergency_mode + label: Emergency mode + description: "1 blocks deposits, borrows and withdrawals; liquidations still allowed. Example: 1" + - path: borrow_disabled + label: Borrowing disabled + description: >- + 1 blocks all new borrows market-wide without touching deposits or withdrawals. Example: 1 + - "autodeleverage_enabled" + - "price_refresh_trigger_to_max_age_pct" + - "liquidation_max_debt_close_factor_pct" + - "insolvency_risk_unhealthy_ltv_pct" + - "min_full_liquidation_value_threshold" + - "max_liquidatable_debt_market_value_at_once" + - "global_allowed_borrow_value" + - "referral_fee_bps" + - "min_value_skip_liquidation_ltv_checks" + - "min_value_skip_liquidation_bf_checks" + - "min_net_value_in_obligation_sf" + - "min_initial_deposit_amount" + - "reserve_rewards_max_apr_bps" + - "obligation_order_execution_enabled" + - "obligation_order_creation_enabled" + - "price_triggered_liquidation_disabled" + - "withdraw_ticket_issuance_enabled" + - "withdraw_ticket_redemption_enabled" + - "withdraw_ticket_cancellation_enabled" + - "min_withdraw_queued_liquidity_value" + - "mature_reserve_debt_liquidation_enabled" + - "term_based_full_liquidation_duration_secs" + - "individual_autodeleverage_margin_call_period_secs" + address: + type: pubkey + llm_context: | + Use this template for market-wide switches, including the gates for two klend 1.23.0 features + that are otherwise configured but never active: + - withdraw_ticket_issuance_enabled / _redemption_enabled / _cancellation_enabled gate the + queued withdrawals set up by kamino-reserve-limits and kamino-withdraw-ticket + - mature_reserve_debt_liquidation_enabled gates the maturity liquidation set up by + kamino-reserve-debt-term + + EXAMPLE - "allow a full position to be closed in one liquidation": + liquidation_max_debt_close_factor_pct: 100 + + EXAMPLE - "wind-down mode" (blocks deposits, borrows and withdrawals, still allows liquidation): + emergency_mode: 1 + + - id: kamino-lending-market-elevation-groups + name: Override Lending Market Elevation Groups + description: Override Kamino e-mode elevation groups + idl_account_name: LendingMarket + properties: + - "elevation_groups" address: type: pubkey + llm_context: | + elevation_groups is a fixed array of EXACTLY 32 entries. Index 0 is the reserved + no-elevation-group slot and its id must stay 0. Prefer element paths for a single group. + + An obligation opts in via elevation_group on kamino-obligation-positions, and the group's + values then override the per-reserve ones. + + EXAMPLE - "e-mode group 1 allows 90% LTV, liquidating at 95%": + elevation_groups.1.ltv_pct: 90 + elevation_groups.1.liquidation_threshold_pct: 95 + elevation_groups.1.allow_new_loans: 1 + diff --git a/crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json b/crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json new file mode 100644 index 000000000..83b32eaeb --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/vault/v1/idl.json @@ -0,0 +1,1781 @@ +{ + "address": "KvauGMspG5k6rtzrqqn7WNn3oZdyKqLKwK2XWQ8FLjd", + "metadata": { + "name": "kamino_vault", + "version": "2.2.2", + "spec": "0.1.0" + }, + "instructions": [], + "accounts": [ + { + "name": "Reserve", + "discriminator": [ + 43, + 242, + 204, + 202, + 26, + 247, + 59, + 127 + ] + }, + { + "name": "GlobalConfig", + "discriminator": [ + 149, + 8, + 156, + 202, + 160, + 252, + 176, + 217 + ] + }, + { + "name": "ReserveWhitelistEntry", + "discriminator": [ + 135, + 130, + 156, + 210, + 58, + 58, + 91, + 170 + ] + }, + { + "name": "VaultState", + "discriminator": [ + 228, + 196, + 82, + 165, + 98, + 210, + 235, + 152 + ] + } + ], + "types": [ + { + "name": "LastUpdate", + "docs": [ + "Last update state" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "slot", + "docs": [ + "Last slot when updated" + ], + "type": "u64" + }, + { + "name": "stale", + "docs": [ + "True when marked stale, false when slot updated" + ], + "type": "u8" + }, + { + "name": "price_status", + "docs": [ + "Status of the prices used to calculate the last update" + ], + "type": "u8" + }, + { + "name": "placeholder", + "type": { + "array": [ + "u8", + 6 + ] + } + } + ] + } + }, + { + "name": "BigFractionBytes", + "type": { + "kind": "struct", + "fields": [ + { + "name": "value", + "type": { + "array": [ + "u64", + 4 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 2 + ] + } + } + ] + } + }, + { + "name": "ReserveCollateral", + "docs": [ + "Reserve collateral" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint_pubkey", + "docs": [ + "Reserve collateral mint address" + ], + "type": "pubkey" + }, + { + "name": "mint_total_supply", + "docs": [ + "Reserve collateral mint supply, used for exchange rate" + ], + "type": "u64" + }, + { + "name": "supply_vault", + "docs": [ + "Reserve collateral supply address" + ], + "type": "pubkey" + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 32 + ] + } + }, + { + "name": "padding2", + "type": { + "array": [ + "u128", + 32 + ] + } + } + ] + } + }, + { + "name": "ReserveConfig", + "docs": [ + "Reserve configuration values" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "status", + "docs": [ + "Status of the reserve Active/Obsolete/Hidden" + ], + "type": "u8" + }, + { + "name": "padding_deprecated_asset_tier", + "docs": [ + "Asset tier -> 0 - regular (collateral & debt), 1 - isolated collateral, 2 - isolated debt" + ], + "type": "u8" + }, + { + "name": "host_fixed_interest_rate_bps", + "docs": [ + "Flat rate that goes to the host" + ], + "type": "u16" + }, + { + "name": "min_deleveraging_bonus_bps", + "docs": [ + "Starting bonus for deleveraging-related liquidations, in bps." + ], + "type": "u16" + }, + { + "name": "block_ctoken_usage", + "docs": [ + "Boolean flag to block minting/redeeming of ctokens", + "Blocks usage of ctokens (minting or withdrawing from obligation)", + "Effectively blocks deposit_reserve_liquidity and withdraw_obligation_collateral" + ], + "type": "u8" + }, + { + "name": "early_repay_remaining_interest_pct", + "docs": [ + "The percentage of remaining interest over the debt term that is charged as early repay penalty.", + "Only meaningful when `debt_term_seconds > 0`." + ], + "type": "u8" + }, + { + "name": "emergency_mode", + "docs": [ + "Whether the reserve is in emergency mode.", + "Blocks most user operations involving this reserve, similar to [LendingMarket::emergency_mode]", + "but scoped to a single reserve. Also cascades to obligations using this reserve as", + "collateral or debt, blocking borrows and withdrawals on other reserves but still", + "allowing repays and deposits." + ], + "type": "u8" + }, + { + "name": "reserved1", + "docs": [ + "Past reserved space - feel free to reuse." + ], + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "protocol_order_execution_fee_pct", + "docs": [ + "Cut of the order execution bonus that the protocol receives, as a percentage" + ], + "type": "u8" + }, + { + "name": "protocol_take_rate_pct", + "docs": [ + "Protocol take rate is the amount borrowed interest protocol receives, as a percentage" + ], + "type": "u8" + }, + { + "name": "protocol_liquidation_fee_pct", + "docs": [ + "Cut of the liquidation bonus that the protocol receives, as a percentage" + ], + "type": "u8" + }, + { + "name": "loan_to_value_pct", + "docs": [ + "Target ratio of the value of borrows to deposits, as a percentage", + "0 if use as collateral is disabled" + ], + "type": "u8" + }, + { + "name": "liquidation_threshold_pct", + "docs": [ + "Loan to value ratio at which an obligation can be liquidated, as percentage" + ], + "type": "u8" + }, + { + "name": "min_liquidation_bonus_bps", + "docs": [ + "Minimum bonus a liquidator receives when repaying part of an unhealthy obligation, as bps" + ], + "type": "u16" + }, + { + "name": "max_liquidation_bonus_bps", + "docs": [ + "Maximum bonus a liquidator receives when repaying part of an unhealthy obligation, as bps" + ], + "type": "u16" + }, + { + "name": "bad_debt_liquidation_bonus_bps", + "docs": [ + "Bad debt liquidation bonus for an undercollateralized obligation, as bps" + ], + "type": "u16" + }, + { + "name": "deleveraging_margin_call_period_secs", + "docs": [ + "Time in seconds that must pass before redemptions are enabled after the deposit limit is", + "crossed.", + "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." + ], + "type": "u64" + }, + { + "name": "deleveraging_threshold_decrease_bps_per_day", + "docs": [ + "The rate at which the deleveraging threshold decreases, in bps per day.", + "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." + ], + "type": "u64" + }, + { + "name": "fees", + "docs": [ + "Program owner fees assessed, separate from gains due to interest accrual" + ], + "type": { + "defined": { + "name": "ReserveFees" + } + } + }, + { + "name": "borrow_rate_curve", + "docs": [ + "Borrow rate curve based on utilization" + ], + "type": { + "defined": { + "name": "BorrowRateCurve" + } + } + }, + { + "name": "borrow_factor_pct", + "docs": [ + "Borrow factor in percentage - used for risk adjustment" + ], + "type": "u64" + }, + { + "name": "deposit_limit", + "docs": [ + "Maximum deposit limit of liquidity in native units, u64::MAX for inf" + ], + "type": "u64" + }, + { + "name": "borrow_limit", + "docs": [ + "Maximum amount borrowed, u64::MAX for inf, 0 to disable borrows (protected deposits)" + ], + "type": "u64" + }, + { + "name": "token_info", + "docs": [ + "Token id from TokenInfos struct" + ], + "type": { + "defined": { + "name": "TokenInfo" + } + } + }, + { + "name": "deposit_withdrawal_cap", + "docs": [ + "Deposit withdrawal caps - deposit & redeem" + ], + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "debt_withdrawal_cap", + "docs": [ + "Debt withdrawal caps - borrow & repay" + ], + "type": { + "defined": { + "name": "WithdrawalCaps" + } + } + }, + { + "name": "elevation_groups", + "type": { + "array": [ + "u8", + 20 + ] + } + }, + { + "name": "disable_usage_as_coll_outside_emode", + "type": "u8" + }, + { + "name": "utilization_limit_block_borrowing_above_pct", + "docs": [ + "Utilization (in percentage) above which borrowing is blocked. 0 to disable." + ], + "type": "u8" + }, + { + "name": "autodeleverage_enabled", + "docs": [ + "Whether this reserve should be subject to auto-deleveraging after deposit or borrow limit is", + "crossed.", + "Besides this flag, the lending market's flag also needs to be enabled (logical `AND`).", + "**NOTE:** the manual \"target LTV\" deleveraging is NOT affected by this flag." + ], + "type": "u8" + }, + { + "name": "proposer_authority_locked", + "docs": [ + "Boolean flag indicating whether the reserve is locked for the proposer authority.", + "", + "Once the proposer have finished preparing the reserve, it must be locked to prevent", + "further changes to the reserve configuration allowing review and voting on the proposal", + "without alteration during the voting period." + ], + "type": "u8" + }, + { + "name": "borrow_limit_outside_elevation_group", + "docs": [ + "Maximum amount liquidity of this reserve borrowed outside all elevation groups", + "- u64::MAX for inf", + "- 0 to disable borrows outside elevation groups" + ], + "type": "u64" + }, + { + "name": "borrow_limit_against_this_collateral_in_elevation_group", + "docs": [ + "Defines the maximum amount (in lamports of elevation group debt asset)", + "that can be borrowed when this reserve is used as collateral.", + "- u64::MAX for inf", + "- 0 to disable borrows in this elevation group (expected value for the debt asset)" + ], + "type": { + "array": [ + "u64", + 32 + ] + } + }, + { + "name": "deleveraging_bonus_increase_bps_per_day", + "docs": [ + "The rate at which the deleveraging-related liquidation bonus increases, in bps per day.", + "Only relevant when `autodeleverage_enabled == 1`, and must not be 0 in such case." + ], + "type": "u64" + }, + { + "name": "debt_maturity_timestamp", + "docs": [ + "The timestamp at which all [Obligation::borrows] using this reserve become liquidatable", + "(on the same terms as reserve-wide deleveraging).", + "Inactive when zeroed (i.e. debt never matures).", + "", + "Note: this feature is independent of [Self::debt_term_seconds] - the liquidation mechanism", + "is based directly on the timestamp defined here, on Reserve's level." + ], + "type": "u64" + }, + { + "name": "debt_term_seconds", + "docs": [ + "The duration after which any debt coming from this Reserve must be repaid.", + "Inactive when zeroed (i.e. funds can be borrowed indefinitely).", + "", + "Note: this feature is independent of [Self::debt_maturity_timestamp] - the liquidation", + "mechanism is based on the [ObligationLiquidity::last_borrowed_at_timestamp]." + ], + "type": "u64" + }, + { + "name": "rewards_amount_per_slot", + "docs": [ + "Rewards distributed per slot to depositors. Drained from", + "[ReserveLiquidity::rewards_amount_available] into", + "[ReserveLiquidity::total_available_amount] at each refresh, capped by the", + "market-level [LendingMarket::reserve_rewards_max_apr_bps]. `0` disables.", + "", + "**Note:** because rewards inflate `total_available_amount`, a non-zero RPS on a", + "reserve with [Self::autodeleverage_enabled] and a finite [Self::deposit_limit]", + "will eventually cross the cap and arm the autodeleverage countdown. Size", + "`deposit_limit` and RPS together." + ], + "type": "u64" + }, + { + "name": "permissioned_ops", + "docs": [ + "Bitmask of [PermissionedOp]s gated by the parent market's `permissioning_authority`", + "when this reserve is the operation's target. `0` = no operation is restricted at the", + "reserve level. Use [Reserve::get_permissioned_ops] for a typed view." + ], + "type": "u64" + } + ] + } + }, + { + "name": "ReserveFees", + "docs": [ + "Additional fee information on a reserve", + "", + "These exist separately from interest accrual fees, and are specifically for the program owner", + "and referral fee. The fees are paid out as a percentage of liquidity token amounts during", + "repayments and liquidations." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "origination_fee_sf", + "docs": [ + "Fee assessed on `BorrowObligationLiquidity`, as scaled fraction (60 bits fractional part)", + "Must be between `0` and `2^60`, such that `2^60 = 1`. A few examples for", + "clarity:", + "1% = (1 << 60) / 100 = 11529215046068470", + "0.01% (1 basis point) = 115292150460685", + "0.00001% (Aave origination fee) = 115292150461" + ], + "type": "u64" + }, + { + "name": "flash_loan_fee_sf", + "docs": [ + "Fee for flash loan, expressed as scaled fraction.", + "0.3% (Aave flash loan fee) = 0.003 * 2^60 = 3458764513820541" + ], + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "Used for allignment" + ], + "type": { + "array": [ + "u8", + 8 + ] + } + } + ] + } + }, + { + "name": "ReserveLiquidity", + "docs": [ + "Reserve liquidity" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "mint_pubkey", + "docs": [ + "Reserve liquidity mint address" + ], + "type": "pubkey" + }, + { + "name": "supply_vault", + "docs": [ + "Reserve liquidity supply address" + ], + "type": "pubkey" + }, + { + "name": "fee_vault", + "docs": [ + "Reserve liquidity fee collection address" + ], + "type": "pubkey" + }, + { + "name": "total_available_amount", + "docs": [ + "Total reserve liquidity available.", + "", + "Note: not all of this liquidity can be freely used for any purpose. Production code should", + "use the specialized getters - see e.g. [Reserve::total_available_liquidity_amount()],", + "[Reserve::freely_available_liquidity_amount()]." + ], + "type": "u64" + }, + { + "name": "borrowed_amount_sf", + "docs": [ + "Reserve liquidity borrowed (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "market_price_sf", + "docs": [ + "Reserve liquidity market price in quote currency (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "market_price_last_updated_ts", + "docs": [ + "Unix timestamp of the market price (from the oracle)" + ], + "type": "u64" + }, + { + "name": "mint_decimals", + "docs": [ + "Reserve liquidity mint decimals" + ], + "type": "u64" + }, + { + "name": "deposit_limit_crossed_timestamp", + "docs": [ + "Timestamp when the last refresh reserve detected that the liquidity amount is above the deposit cap. When this threshold is crossed, then redemptions (auto-deleverage) are enabled.", + "If the threshold is not crossed, then the timestamp is set to 0" + ], + "type": "u64" + }, + { + "name": "borrow_limit_crossed_timestamp", + "docs": [ + "Timestamp when the last refresh reserve detected that the borrowed amount is above the borrow cap. When this threshold is crossed, then redemptions (auto-deleverage) are enabled.", + "If the threshold is not crossed, then the timestamp is set to 0" + ], + "type": "u64" + }, + { + "name": "cumulative_borrow_rate_bsf", + "docs": [ + "Reserve liquidity cumulative borrow rate (scaled fraction)" + ], + "type": { + "defined": { + "name": "BigFractionBytes" + } + } + }, + { + "name": "accumulated_protocol_fees_sf", + "docs": [ + "Reserve cumulative protocol fees (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "accumulated_referrer_fees_sf", + "docs": [ + "Reserve cumulative referrer fees (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "pending_referrer_fees_sf", + "docs": [ + "Reserve pending referrer fees, to be claimed in refresh_obligation by referrer or protocol (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "absolute_referral_rate_sf", + "docs": [ + "Reserve referrer fee absolute rate calculated at each refresh_reserve operation (scaled fraction)" + ], + "type": "u128" + }, + { + "name": "token_program", + "docs": [ + "Token program of the liquidity mint" + ], + "type": "pubkey" + }, + { + "name": "rewards_amount_available", + "docs": [ + "Reserve rewards budget remaining for distribution.", + "", + "Tokens are deposited via `topup_reserve_rewards` and increase this counter (without", + "touching [Self::total_available_amount]). On every `refresh_reserve`, up to", + "`rewards_amount_per_slot * slots_elapsed` tokens are moved from this counter into", + "[Self::total_available_amount], inflating the cToken exchange rate, capped by the", + "market-level `reserve_rewards_max_apr_bps` cap." + ], + "type": "u64" + }, + { + "name": "padding2", + "type": { + "array": [ + "u64", + 50 + ] + } + }, + { + "name": "padding3", + "type": { + "array": [ + "u128", + 32 + ] + } + } + ] + } + }, + { + "name": "WithdrawQueue", + "docs": [ + "A tracker of ticket-based withdrawals." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "queued_collateral_amount", + "docs": [ + "The part of [ReserveLiquidity::total_available_amount] locked for ticketed withdrawals." + ], + "type": "u64" + }, + { + "name": "next_issued_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be issued when enqueueing to withdraw.", + "Note: it is also a number of tickets issued so far." + ], + "type": "u64" + }, + { + "name": "next_withdrawable_ticket_sequence_number", + "docs": [ + "The sequence number of the next ticket to be used for actually transferring the withdrawn", + "liquidity (assuming it is available in the reserve).", + "Note: it is also a number of fully-consumed tickets so far." + ], + "type": "u64" + } + ] + } + }, + { + "name": "WithdrawalCaps", + "docs": [ + "Reserve Withdrawal Caps State" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "config_capacity", + "type": "i64" + }, + { + "name": "current_total", + "type": "i64" + }, + { + "name": "last_interval_start_timestamp", + "type": "u64" + }, + { + "name": "config_interval_length_seconds", + "type": "u64" + } + ] + } + }, + { + "name": "PriceHeuristic", + "type": { + "kind": "struct", + "fields": [ + { + "name": "lower", + "docs": [ + "Lower value of acceptable price" + ], + "type": "u64" + }, + { + "name": "upper", + "docs": [ + "Upper value of acceptable price" + ], + "type": "u64" + }, + { + "name": "exp", + "docs": [ + "Number of decimals of the previously defined values" + ], + "type": "u64" + } + ] + } + }, + { + "name": "PythConfiguration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price", + "docs": [ + "Pubkey of the base price feed (disabled if `null` or `default`)" + ], + "type": "pubkey" + } + ] + } + }, + { + "name": "ScopeConfiguration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price_feed", + "docs": [ + "Pubkey of the scope price feed (disabled if `null` or `default`)" + ], + "type": "pubkey" + }, + { + "name": "price_chain", + "docs": [ + "This is the scope_id price chain that results in a price for the token" + ], + "type": { + "array": [ + "u16", + 4 + ] + } + }, + { + "name": "twap_chain", + "docs": [ + "This is the scope_id price chain for the twap" + ], + "type": { + "array": [ + "u16", + 4 + ] + } + } + ] + } + }, + { + "name": "SwitchboardConfiguration", + "type": { + "kind": "struct", + "fields": [ + { + "name": "price_aggregator", + "docs": [ + "Pubkey of the base price feed (disabled if `null` or `default`)" + ], + "type": "pubkey" + }, + { + "name": "twap_aggregator", + "type": "pubkey" + } + ] + } + }, + { + "name": "TokenInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "name", + "docs": [ + "UTF-8 encoded name of the token (null-terminated)" + ], + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "heuristic", + "docs": [ + "Heuristics limits of acceptable price" + ], + "type": { + "defined": { + "name": "PriceHeuristic" + } + } + }, + { + "name": "max_twap_divergence_bps", + "docs": [ + "Max divergence between twap and price in bps" + ], + "type": "u64" + }, + { + "name": "max_age_price_seconds", + "type": "u64" + }, + { + "name": "max_age_twap_seconds", + "type": "u64" + }, + { + "name": "scope_configuration", + "docs": [ + "Scope price configuration" + ], + "type": { + "defined": { + "name": "ScopeConfiguration" + } + } + }, + { + "name": "switchboard_configuration", + "docs": [ + "Switchboard configuration" + ], + "type": { + "defined": { + "name": "SwitchboardConfiguration" + } + } + }, + { + "name": "pyth_configuration", + "docs": [ + "Pyth configuration" + ], + "type": { + "defined": { + "name": "PythConfiguration" + } + } + }, + { + "name": "block_price_usage", + "type": "u8" + }, + { + "name": "reserved", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 19 + ] + } + } + ] + } + }, + { + "name": "BorrowRateCurve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "points", + "type": { + "array": [ + { + "defined": { + "name": "CurvePoint" + } + }, + 11 + ] + } + } + ] + } + }, + { + "name": "CurvePoint", + "type": { + "kind": "struct", + "fields": [ + { + "name": "utilization_rate_bps", + "type": "u32" + }, + { + "name": "borrow_rate_bps", + "type": "u32" + } + ] + } + }, + { + "name": "UpdateReserveWhitelistMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Invest", + "fields": [ + "u8" + ] + }, + { + "name": "AddAllocation", + "fields": [ + "u8" + ] + } + ] + } + }, + { + "name": "VaultConfigField", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PerformanceFeeBps" + }, + { + "name": "ManagementFeeBps" + }, + { + "name": "MinDepositAmount" + }, + { + "name": "MinWithdrawAmount" + }, + { + "name": "MinInvestAmount" + }, + { + "name": "MinInvestDelaySlots" + }, + { + "name": "CrankFundFeePerReserve" + }, + { + "name": "PendingVaultAdmin" + }, + { + "name": "Name" + }, + { + "name": "LookupTable" + }, + { + "name": "Farm" + }, + { + "name": "AllocationAdmin" + }, + { + "name": "UnallocatedWeight" + }, + { + "name": "UnallocatedTokensCap" + }, + { + "name": "WithdrawalPenaltyLamports" + }, + { + "name": "WithdrawalPenaltyBps" + }, + { + "name": "FirstLossCapitalFarm" + }, + { + "name": "AllowAllocationsInWhitelistedReservesOnly" + }, + { + "name": "AllowInvestInWhitelistedReservesOnly" + }, + { + "name": "RewardPerSecond" + }, + { + "name": "DepositCap" + } + ] + } + }, + { + "name": "VaultAllocation", + "type": { + "kind": "struct", + "fields": [ + { + "name": "reserve", + "type": "pubkey" + }, + { + "name": "ctoken_vault", + "type": "pubkey" + }, + { + "name": "target_allocation_weight", + "type": "u64" + }, + { + "name": "token_allocation_cap", + "docs": [ + "Maximum token invested in this reserve" + ], + "type": "u64" + }, + { + "name": "ctoken_vault_bump", + "type": "u64" + }, + { + "name": "ctoken_allocation_cap", + "type": "u64" + }, + { + "name": "config_padding", + "type": { + "array": [ + "u64", + 126 + ] + } + }, + { + "name": "ctoken_allocation", + "type": "u64" + }, + { + "name": "last_invest_slot", + "type": "u64" + }, + { + "name": "token_target_allocation_sf", + "type": "u128" + }, + { + "name": "state_padding", + "type": { + "array": [ + "u64", + 128 + ] + } + } + ] + } + }, + { + "name": "VaultRewardInfo", + "type": { + "kind": "struct", + "fields": [ + { + "name": "reward_per_second", + "type": "u64" + }, + { + "name": "last_issuance_ts", + "type": "u64" + }, + { + "name": "rewards_available", + "docs": [ + "Rewards available to distribute (topped up but not yet moved to vault.token_available)" + ], + "type": "u64" + }, + { + "name": "cumulative_rewards_distributed_analytics", + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 8 + ] + } + } + ] + } + }, + { + "name": "UpdateGlobalConfigMode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PendingAdmin", + "fields": [ + "pubkey" + ] + }, + { + "name": "MinWithdrawalPenaltyLamports", + "fields": [ + "u64" + ] + }, + { + "name": "MinWithdrawalPenaltyBPS", + "fields": [ + "u64" + ] + } + ] + } + }, + { + "name": "Reserve", + "type": { + "kind": "struct", + "fields": [ + { + "name": "version", + "docs": [ + "Version of the reserve" + ], + "type": "u64" + }, + { + "name": "last_update", + "docs": [ + "Last slot when supply and rates updated" + ], + "type": { + "defined": { + "name": "LastUpdate" + } + } + }, + { + "name": "lending_market", + "docs": [ + "Lending market address" + ], + "type": "pubkey" + }, + { + "name": "farm_collateral", + "type": "pubkey" + }, + { + "name": "farm_debt", + "type": "pubkey" + }, + { + "name": "liquidity", + "docs": [ + "Reserve liquidity" + ], + "type": { + "defined": { + "name": "ReserveLiquidity" + } + } + }, + { + "name": "reserve_liquidity_padding", + "type": { + "array": [ + "u64", + 150 + ] + } + }, + { + "name": "collateral", + "docs": [ + "Reserve collateral" + ], + "type": { + "defined": { + "name": "ReserveCollateral" + } + } + }, + { + "name": "reserve_collateral_padding", + "type": { + "array": [ + "u64", + 150 + ] + } + }, + { + "name": "config", + "docs": [ + "Reserve configuration values" + ], + "type": { + "defined": { + "name": "ReserveConfig" + } + } + }, + { + "name": "config_padding", + "type": { + "array": [ + "u64", + 112 + ] + } + }, + { + "name": "borrowed_amount_outside_elevation_group", + "type": "u64" + }, + { + "name": "borrowed_amounts_against_this_reserve_in_elevation_groups", + "docs": [ + "Amount of token borrowed in lamport of debt asset in the given", + "elevation group when this reserve is part of the collaterals." + ], + "type": { + "array": [ + "u64", + 32 + ] + } + }, + { + "name": "withdraw_queue", + "docs": [ + "The tracker of ticket-based withdrawals." + ], + "type": { + "defined": { + "name": "WithdrawQueue" + } + } + }, + { + "name": "padding", + "type": { + "array": [ + "u64", + 204 + ] + } + } + ] + } + }, + { + "name": "GlobalConfig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "global_admin", + "type": "pubkey" + }, + { + "name": "pending_admin", + "type": "pubkey" + }, + { + "name": "withdrawal_penalty_lamports", + "type": "u64" + }, + { + "name": "withdrawal_penalty_bps", + "type": "u64" + }, + { + "name": "padding", + "type": { + "array": [ + "u8", + 944 + ] + } + } + ] + } + }, + { + "name": "ReserveWhitelistEntry", + "type": { + "kind": "struct", + "fields": [ + { + "name": "token_mint", + "docs": [ + "The token mint is stored to solve the problem of finding all the whitelisted reserves for a particular token mint:", + "when storing the token mint inside the PDA, finding all the whitelisted reserves becomes a `getProgramAccounts` with", + "a filter on discriminator + the mint field", + "The reserve pubkey, as seed of the reserve whitelist PDA account, is stored so you can link back the PDA to its seeds", + "(for instance, in the operation above we easily find the reserve corresponding to the PDA)" + ], + "type": "pubkey" + }, + { + "name": "reserve", + "type": "pubkey" + }, + { + "name": "whitelist_add_allocation", + "type": "u8" + }, + { + "name": "whitelist_invest", + "type": "u8" + }, + { + "name": "padding", + "type": { + "array": [ + "u8", + 62 + ] + } + } + ] + } + }, + { + "name": "VaultState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "vault_admin_authority", + "type": "pubkey" + }, + { + "name": "base_vault_authority", + "type": "pubkey" + }, + { + "name": "base_vault_authority_bump", + "type": "u64" + }, + { + "name": "token_mint", + "type": "pubkey" + }, + { + "name": "token_mint_decimals", + "type": "u64" + }, + { + "name": "token_vault", + "type": "pubkey" + }, + { + "name": "token_program", + "type": "pubkey" + }, + { + "name": "shares_mint", + "type": "pubkey" + }, + { + "name": "shares_mint_decimals", + "type": "u64" + }, + { + "name": "token_available", + "type": "u64" + }, + { + "name": "shares_issued", + "type": "u64" + }, + { + "name": "available_crank_funds", + "type": "u64" + }, + { + "name": "unallocated_weight", + "type": "u64" + }, + { + "name": "performance_fee_bps", + "type": "u64" + }, + { + "name": "management_fee_bps", + "type": "u64" + }, + { + "name": "last_fee_charge_timestamp", + "type": "u64" + }, + { + "name": "prev_aum_sf", + "type": "u128" + }, + { + "name": "pending_fees_sf", + "type": "u128" + }, + { + "name": "vault_allocation_strategy", + "type": { + "array": [ + { + "defined": { + "name": "VaultAllocation" + } + }, + 25 + ] + } + }, + { + "name": "padding1", + "type": { + "array": [ + "u128", + 256 + ] + } + }, + { + "name": "min_deposit_amount", + "type": "u64" + }, + { + "name": "min_withdraw_amount", + "type": "u64" + }, + { + "name": "min_invest_amount", + "type": "u64" + }, + { + "name": "min_invest_delay_slots", + "type": "u64" + }, + { + "name": "crank_fund_fee_per_reserve", + "type": "u64" + }, + { + "name": "pending_admin", + "type": "pubkey" + }, + { + "name": "cumulative_earned_interest_sf", + "type": "u128" + }, + { + "name": "cumulative_mgmt_fees_sf", + "type": "u128" + }, + { + "name": "cumulative_perf_fees_sf", + "type": "u128" + }, + { + "name": "name", + "type": { + "array": [ + "u8", + 40 + ] + } + }, + { + "name": "vault_lookup_table", + "type": "pubkey" + }, + { + "name": "vault_farm", + "type": "pubkey" + }, + { + "name": "creation_timestamp", + "type": "u64" + }, + { + "name": "unallocated_tokens_cap", + "type": "u64" + }, + { + "name": "allocation_admin", + "type": "pubkey" + }, + { + "name": "withdrawal_penalty_lamports", + "type": "u64" + }, + { + "name": "withdrawal_penalty_bps", + "type": "u64" + }, + { + "name": "first_loss_capital_farm", + "type": "pubkey" + }, + { + "name": "allow_allocations_in_whitelisted_reserves_only", + "type": "u8" + }, + { + "name": "allow_invest_in_whitelisted_reserves_only", + "type": "u8" + }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 6 + ] + } + }, + { + "name": "deposit_cap", + "docs": [ + "total vault deposit cap; 0 means uncapped for backward compatibility reasons; this is a soft cap that just blocks new deposits but the vault AUM can go above this cap because of the earned interest" + ], + "type": "u64" + }, + { + "name": "reward_info", + "type": { + "defined": { + "name": "VaultRewardInfo" + } + } + }, + { + "name": "padding3", + "type": { + "array": [ + "u128", + 232 + ] + } + } + ] + } + }, + { + "name": "DepositResultEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_to_mint", + "type": "u64" + }, + { + "name": "token_to_deposit", + "type": "u64" + }, + { + "name": "crank_funds_to_deposit", + "type": "u64" + } + ] + } + }, + { + "name": "DepositUserAtaBalanceEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user_ata_balance", + "type": "u64" + } + ] + } + }, + { + "name": "RedeemInKindResultEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_to_burn", + "type": "u64" + }, + { + "name": "ctokens_to_send_to_user", + "type": "u64" + } + ] + } + }, + { + "name": "SharesToWithdrawEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_amount", + "type": "u64" + }, + { + "name": "user_shares_before", + "type": "u64" + } + ] + } + }, + { + "name": "WithdrawResultEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "shares_to_burn", + "type": "u64" + }, + { + "name": "available_to_send_to_user", + "type": "u64" + }, + { + "name": "invested_to_disinvest_ctokens", + "type": "u64" + }, + { + "name": "invested_liquidity_to_send_to_user", + "type": "u64" + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml new file mode 100644 index 000000000..f8d190d83 --- /dev/null +++ b/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml @@ -0,0 +1,224 @@ +protocol: kamino-vault +version: v2.2.2 +account_type: VaultState +idl_file_path: idl.json + +tags: + - vault + - yield + - lending + - defi + +templates: + - id: kamino-vault-state + name: Override Earn Vault Balances + description: Override Kamino Earn vault balances and deposit limits + idl_account_name: VaultState + # Do not add `token_mint`, `shares_mint` or their `*_decimals` here. The mints are wiring + # for token accounts that must exist and be funded, so repointing them only produces a + # broken vault - fork a real one instead. The decimals are cached copies of the SPL mints + # and changing them silently desyncs every amount. + properties: + - path: token_available + label: Idle tokens + description: "Tokens idle in the vault, not yet lent out. Example: 1000000000" + - path: shares_issued + label: Shares outstanding + description: "Total shares held by depositors. Example: 1000000000" + - path: prev_aum_sf + label: AUM at last fee charge + description: "AUM recorded at the last fee charge (scaled fraction, x2^60). Example: 1000 x 2^60" + - "deposit_cap" + - path: min_deposit_amount + label: Minimum deposit + description: "Smallest accepted deposit, in the token's smallest unit. Example: 1000000" + - path: min_withdraw_amount + label: Minimum withdrawal + description: "Smallest accepted withdrawal, smallest unit. Example: 1000000" + - path: min_invest_amount + label: Minimum invest + description: "Least the vault will deploy into a reserve in one go, smallest unit. Example: 1000000" + - path: min_invest_delay_slots + label: Invest cooldown + description: "Slots that must pass between deployments. Example: 0" + - path: unallocated_tokens_cap + label: Idle ceiling + description: "Most the vault will deliberately leave idle, smallest unit. Example: 1000000000" + - path: unallocated_weight + label: Idle weight + description: >- + The idle bucket's share of the vault, expressed relative to the reserve weights rather than as + a percentage. Example: 100 + address: + type: pubkey + llm_context: | + Share price = total assets (token_available plus what is deployed into reserves) / + shares_issued. + + HOW TO USE THIS TEMPLATE: + 1. Raise token_available alone to simulate the vault earning yield + 2. Raise shares_issued alone to dilute every holder + 3. Set deposit_cap: 0 to block new deposits + + EXAMPLE - "the vault earned 1000 USDC of yield" (6 decimals): + token_available: 1000000000 + + DO NOT set persist: true here - transactions write these fields, and re-applying the + override reverts their writes at the start of every following slot. + + - id: kamino-vault-fees + name: Override Earn Vault Fees + description: Override Kamino Earn vault performance, management and exit fees + idl_account_name: VaultState + properties: + - path: performance_fee_bps + label: Performance fee + description: "Charged on yield the vault earns in bps. Example: 0" + - path: management_fee_bps + label: Management fee + description: "Charged annually on assets held in bps. Example: 0" + - path: last_fee_charge_timestamp + label: Last fee charge + description: "When fees were last taken (unix seconds). Example: 1780000000" + - path: pending_fees_sf + label: Accrued fees + description: "Fees accrued but not yet taken (scaled fraction, x2^60). Example: 0" + - path: withdrawal_penalty_bps + label: Withdrawal penalty + description: "Exit fee charged on withdrawal, in bps. Example: 100" + - path: withdrawal_penalty_lamports + label: Withdrawal penalty (lamports) + description: "Flat SOL charge on withdrawal, in lamports. Example: 0" + address: + type: pubkey + llm_context: | + Use this template to isolate depositor returns from fees, or to stress the fee maths. + + HOW TO USE THIS TEMPLATE: + 1. Set both fee rates to 0 to remove fees from a share-price assertion + 2. Move last_fee_charge_timestamp into the past so the next charge covers a longer period, + which simulates elapsed time without waiting + + EXAMPLE - "no fees": + performance_fee_bps: 0 + management_fee_bps: 0 + + - id: kamino-vault-allocation + name: Override Earn Vault Allocation + description: Override how a Kamino Earn vault spreads deposits across reserves + idl_account_name: VaultState + properties: + - path: vault_allocation_strategy.0.reserve + label: Target reserve + description: >- + The Kamino Lend reserve this slot lends into. Example: + D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 + - path: vault_allocation_strategy.0.target_allocation_weight + label: Target weight + description: "This reserve's share of the vault, a proportion not a percentage. Example: 0" + - "vault_allocation_strategy.0.token_allocation_cap" + - path: vault_allocation_strategy.0.ctoken_allocation + label: Collateral held + description: >- + How many of the reserve's collateral tokens the vault currently holds there, smallest unit. + Example: 1000000000 + - path: vault_allocation_strategy.0.ctoken_allocation_cap + label: Collateral cap + description: "Ceiling on collateral tokens held in this reserve, smallest unit. Example: 1000000000" + - path: vault_allocation_strategy.0.token_target_allocation_sf + label: Target amount + description: "Target holding for this reserve (scaled fraction, x2^60). Example: 1000 x 2^60" + - path: vault_allocation_strategy.0.last_invest_slot + label: Last invest slot + description: "Slot at which the vault last deployed into this reserve. Example: 370000000" + - path: allow_allocations_in_whitelisted_reserves_only + label: Whitelist allocations + description: "1 restricts which reserves may be given a weight to whitelisted ones. Example: 1" + - path: allow_invest_in_whitelisted_reserves_only + label: Whitelist investing + description: "1 restricts actual deployment to whitelisted reserves. Example: 1" + address: + type: pubkey + llm_context: | + vault_allocation_strategy has 25 slots - replace the 0 in the property paths to target slots + 0-24. Weights are PROPORTIONS relative to each other plus unallocated_weight, not percentages. + + HOW TO USE THIS TEMPLATE: + 1. Set vault_allocation_strategy.N.reserve to the Kamino Lend reserve for that slot + 2. Set target_allocation_weight: 0 to make the vault withdraw from it on the next crank + 3. To set up a withdrawal failure, concentrate the full weight into one reserve and then make + that reserve illiquid with kamino-reserve-limits + + EXAMPLE - "pull out of this reserve": + vault_allocation_strategy.0.target_allocation_weight: 0 + + - id: kamino-vault-rewards + name: Override Earn Vault Rewards + description: Override Kamino Earn vault reward emissions + idl_account_name: VaultState + properties: + - path: reward_info.reward_per_second + label: Emission rate + description: >- + Rewards paid to vault depositors per second, in the reward token's smallest unit. Example: + 1000 + - "reward_info.rewards_available" + - path: reward_info.last_issuance_ts + label: Last issuance time + description: "When vault rewards last accrued (unix seconds). Example: 1780000000" + - path: vault_farm + label: Linked farm + description: >- + Kamino Farms account if the vault also distributes through Farms. Example: the farm's address + - path: first_loss_capital_farm + label: First-loss farm + description: >- + Farm holding first-loss capital that absorbs losses before depositors. Example: the farm's + address + address: + type: pubkey + llm_context: | + A THIRD reward mechanism, separate from reserve rewards (kamino-reserve-rewards) and Kamino + Farms (kamino-farms-*). This one pays vault depositors directly. + + HOW TO USE THIS TEMPLATE: + 1. Set reward_info.reward_per_second to the emission rate + 2. Raise reward_info.rewards_available too, or emissions stop when the budget empties + 3. Move reward_info.last_issuance_ts backwards to accrue a longer period without waiting + 4. When vault_farm is set, the vault also distributes through Farms - use the kamino-farms-* + templates for the per-user side + + EXAMPLE - "emit 1 USDC per second" (6 decimals): + reward_info.reward_per_second: 1000000 + reward_info.rewards_available: 1000000000 + + - id: kamino-vault-reserve-whitelist + name: Override Earn Vault Reserve Whitelist + description: Override a Kamino Earn vault reserve whitelist entry + idl_account_name: ReserveWhitelistEntry + properties: + - "token_mint" + - path: reserve + label: Reserve + description: >- + The Kamino Lend reserve this entry whitelists. Example: + D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 (Main Market USDC) + - path: whitelist_add_allocation + label: May be allocated + description: "1 permits the vault to give this reserve a target weight, 0 refuses it. Example: 1" + - path: whitelist_invest + label: May be invested in + description: "1 permits the vault to actually deploy funds into this reserve, 0 refuses it. Example: 1" + address: + type: pubkey + llm_context: | + CRITICAL: Without an entry here, the whitelist switches on kamino-vault-allocation can only be + turned on - with nothing whitelisted, every allocation is refused. Build one with + surfnet_setAccount, since a vault that has never used whitelisting has no entries. + + One account per (vault, reserve) pair. + + EXAMPLE - "this reserve is approved for both allocation and investment": + reserve: D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 + whitelist_add_allocation: 1 + whitelist_invest: 1 \ No newline at end of file diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 9d69b0eee..5650564d9 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -23,6 +23,27 @@ pub const METEORA_DLMM_OVERRIDES_CONTENT: &str = pub const KAMINO_V1_IDL_CONTENT: &str = include_str!("./protocols/kamino/v1/idl.json"); pub const KAMINO_V1_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/v1/overrides.yaml"); +pub const KAMINO_SCOPE_IDL_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/idl.json"); +pub const KAMINO_SCOPE_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/scope/v1/overrides.yaml"); + +pub const KAMINO_FARMS_IDL_CONTENT: &str = include_str!("./protocols/kamino/farms/v1/idl.json"); +pub const KAMINO_FARMS_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/farms/v1/overrides.yaml"); + +pub const KAMINO_SWAP_IDL_CONTENT: &str = include_str!("./protocols/kamino/swap/v1/idl.json"); +pub const KAMINO_SWAP_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/swap/v1/overrides.yaml"); + +pub const KAMINO_VAULT_IDL_CONTENT: &str = include_str!("./protocols/kamino/vault/v1/idl.json"); +pub const KAMINO_VAULT_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/vault/v1/overrides.yaml"); + +pub const KAMINO_LIQUIDITY_IDL_CONTENT: &str = + include_str!("./protocols/kamino/liquidity/v1/idl.json"); +pub const KAMINO_LIQUIDITY_OVERRIDES_CONTENT: &str = + include_str!("./protocols/kamino/liquidity/v1/overrides.yaml"); + pub const DRIFT_V2_IDL_CONTENT: &str = include_str!("./protocols/drift/v2/idl.json"); pub const DRIFT_V2_OVERRIDES_CONTENT: &str = include_str!("./protocols/drift/v2/overrides.yaml"); @@ -89,6 +110,36 @@ impl TemplateRegistry { pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); + + self.load_protocol_overrides( + KAMINO_SCOPE_IDL_CONTENT, + KAMINO_SCOPE_OVERRIDES_CONTENT, + "kamino-scope", + ); + + self.load_protocol_overrides( + KAMINO_FARMS_IDL_CONTENT, + KAMINO_FARMS_OVERRIDES_CONTENT, + "kamino-farms", + ); + + self.load_protocol_overrides( + KAMINO_SWAP_IDL_CONTENT, + KAMINO_SWAP_OVERRIDES_CONTENT, + "kamino-swap", + ); + + self.load_protocol_overrides( + KAMINO_VAULT_IDL_CONTENT, + KAMINO_VAULT_OVERRIDES_CONTENT, + "kamino-vault", + ); + + self.load_protocol_overrides( + KAMINO_LIQUIDITY_IDL_CONTENT, + KAMINO_LIQUIDITY_OVERRIDES_CONTENT, + "kamino-liquidity", + ); } pub fn load_drift_overrides(&mut self) { @@ -182,7 +233,8 @@ impl TemplateRegistry { #[cfg(test)] mod tests { - use std::{collections::HashMap, str::FromStr}; + use anchor_lang_idl::types::IdlType; + use std::{collections::HashMap, collections::BTreeSet, str::FromStr}; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, PdaSeed}; @@ -329,11 +381,11 @@ mod tests { fn test_registry_loads_all_protocols() { let registry = TemplateRegistry::new(); - // Should have Pyth (1 template) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift(4) + Meteora (2) + Kamino(3) + Whirlpool(6) + SPL Token (2) = 24 total + // Should have Pyth (1 template) + 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) + Whirlpool(6) + SPL Token (2) = 57 total assert_eq!( registry.count(), - 24, - "Registry should load 24 templates total" + 57, + "Registry should load 57 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); @@ -352,7 +404,36 @@ mod tests { assert!(registry.contains("kamino-reserve-state")); assert!(registry.contains("kamino-reserve-config")); + assert!(registry.contains("kamino-reserve-status")); + assert!(registry.contains("kamino-reserve-limits")); + assert!(registry.contains("kamino-reserve-fees")); + assert!(registry.contains("kamino-reserve-interest-rate")); + assert!(registry.contains("kamino-reserve-oracle")); assert!(registry.contains("kamino-obligation-health")); + assert!(registry.contains("kamino-obligation-positions")); + assert!(registry.contains("kamino-obligation-orders")); + assert!(registry.contains("kamino-lending-market-risk")); + assert!(registry.contains("kamino-lending-market-elevation-groups")); + assert!(registry.contains("kamino-reserve-rewards")); + assert!(registry.contains("kamino-reserve-debt-term")); + assert!(registry.contains("kamino-withdraw-ticket")); + assert!(registry.contains("kamino-scope-price")); + assert!(registry.contains("kamino-scope-price-source")); + assert!(registry.contains("kamino-scope-twap")); + assert!(registry.contains("kamino-farms-reward-emissions")); + assert!(registry.contains("kamino-farms-reward-accumulator")); + assert!(registry.contains("kamino-farms-user-rewards")); + assert!(registry.contains("kamino-farms-farm-config")); + assert!(registry.contains("kamino-farms-global-config")); + assert!(registry.contains("kamino-swap-order")); + assert!(registry.contains("kamino-swap-global-config")); + assert!(registry.contains("kamino-vault-state")); + assert!(registry.contains("kamino-vault-allocation")); + assert!(registry.contains("kamino-vault-rewards")); + assert!(registry.contains("kamino-vault-reserve-whitelist")); + assert!(registry.contains("kamino-liquidity-strategy-balances")); + assert!(registry.contains("kamino-liquidity-strategy-rewards")); + assert!(registry.contains("kamino-liquidity-strategy-guards")); assert!(registry.contains("drift-perp-market")); assert!(registry.contains("drift-spot-market")); @@ -409,8 +490,70 @@ mod tests { "Should have 5 Raydium templates (1 CLMM + 4 AMM v4)" ); - let kamino_templates = registry.by_protocol("Kamino"); - assert_eq!(kamino_templates.len(), 3, "Should have 3 Kamino templates"); + let kamino_templates = registry.by_protocol("kamino"); + assert_eq!( + kamino_templates.len(), + 17, + "Should have 17 Kamino Lend templates" + ); + assert_eq!( + registry.by_protocol("kamino-scope").len(), + 3, + "Should have 3 Kamino Scope templates" + ); + assert_eq!( + registry.by_protocol("kamino-farms").len(), + 5, + "Should have 5 Kamino Farms templates" + ); + assert_eq!( + registry.by_protocol("kamino-swap").len(), + 2, + "Should have 2 Kamino Swap templates" + ); + assert_eq!( + registry.by_protocol("kamino-vault").len(), + 5, + "Should have 5 Kamino Earn vault templates" + ); + assert_eq!( + registry.by_protocol("kamino-liquidity").len(), + 4, + "Should have 4 Kamino Liquidity templates" + ); + + // Each Kamino-family protocol must cover the accounts worth overriding + for (protocol, expected_accounts) in [ + ( + "kamino", + vec!["Reserve", "Obligation", "LendingMarket", "WithdrawTicket"], + ), + ( + "kamino-scope", + vec!["OraclePrices", "OracleMappings", "OracleTwaps"], + ), + ( + "kamino-farms", + vec!["FarmState", "UserState", "GlobalConfig"], + ), + ("kamino-swap", vec!["Order", "GlobalConfig"]), + ("kamino-vault", vec!["VaultState", "ReserveWhitelistEntry"]), + ("kamino-liquidity", vec!["WhirlpoolStrategy"]), + ] { + let account_types: BTreeSet<&str> = registry + .by_protocol(protocol) + .iter() + .map(|t| t.account_type.as_str()) + .collect(); + for expected in expected_accounts { + assert!( + account_types.contains(expected), + "{} should have at least one template for the {} account", + protocol, + expected + ); + } + } let whirlpool_templates = registry.by_protocol("Whirlpool"); assert_eq!( @@ -427,8 +570,15 @@ mod tests { let oracle_templates = registry.by_tags(&[vec!["oracle".to_string()]].concat()); assert_eq!( oracle_templates.len(), - 1, - "Should find 1 oracle template (Pyth)" + 4, + "Should find 4 oracle templates (Pyth + 3 Kamino Scope)" + ); + + let rewards_templates = registry.by_tags(&[vec!["rewards".to_string()]].concat()); + assert_eq!( + rewards_templates.len(), + 5, + "Should find 5 rewards templates (Kamino Farms)" ); let dex_templates = registry.by_tags(&[vec!["dex".to_string()]].concat()); @@ -473,6 +623,11 @@ mod tests { assert!(ids.contains(&"kamino-reserve-state".to_string())); assert!(ids.contains(&"kamino-reserve-config".to_string())); assert!(ids.contains(&"kamino-obligation-health".to_string())); + assert!(ids.contains(&"kamino-obligation-positions".to_string())); + assert!(ids.contains(&"kamino-reserve-oracle".to_string())); + assert!(ids.contains(&"kamino-lending-market-risk".to_string())); + assert!(ids.contains(&"kamino-scope-price".to_string())); + assert!(ids.contains(&"kamino-farms-user-rewards".to_string())); assert!(ids.contains(&"drift-perp-market".to_string())); assert!(ids.contains(&"whirlpool-sol-usdc".to_string())); assert!(ids.contains(&"whirlpool-sol-usdt".to_string())); @@ -877,4 +1032,249 @@ mod tests { resolved_address, expected_address ); } + + /// A property that does not exist in the IDL is dropped at materialization time with only + /// a warning, so the scenario appears to run while changing nothing. + #[test] + fn test_all_template_property_paths_exist_in_idl() { + let registry = TemplateRegistry::new(); + let mut errors = Vec::new(); + + for template in registry.all() { + for property in &template.properties { + // constant_ref properties are UI dropdowns (e.g. token pickers), not + // account fields, so they are not expected to resolve against the IDL. + if property.is_constant_ref() { + continue; + } + if let Err(e) = surfpool_types::resolve_idl_type( + &template.idl, + &template.account_type, + &property.path, + ) { + errors.push(format!("[{}] {}: {}", template.id, property.path, e)); + } + } + } + + assert!( + errors.is_empty(), + "{} template propert(ies) do not exist in their IDL:\n {}", + errors.len(), + errors.join("\n ") + ); + } + + #[test] + fn test_array_index_override_path_errors() { + use txtx_addon_kit::{indexmap::IndexMap, types::types::Value}; + + use crate::surfnet::svm::apply_override_to_decoded_account; + + let mut decoded = Value::Object(IndexMap::from([( + "deposits".to_string(), + Value::Array(Box::new(vec![Value::Integer(1), Value::Integer(2)])), + )])); + + assert!( + apply_override_to_decoded_account(&mut decoded, "deposits.1", &serde_json::json!(9)) + .is_ok() + ); + match &decoded { + Value::Object(map) => match map.get("deposits") { + Some(Value::Array(items)) => assert_eq!(items[1], Value::Integer(9)), + _ => panic!("expected deposits array"), + }, + _ => panic!("expected object"), + } + + // out-of-bounds index + let err = + apply_override_to_decoded_account(&mut decoded, "deposits.7", &serde_json::json!(1)) + .expect_err("index 7 is out of bounds for a 2-element array"); + assert!( + format!("{err}").contains("out of bounds"), + "unexpected error: {err}" + ); + + // non-numeric segment on an array + let err = apply_override_to_decoded_account( + &mut decoded, + "deposits.first", + &serde_json::json!(1), + ) + .expect_err("'first' is not an array index"); + assert!( + format!("{err}").contains("zero-based array index"), + "unexpected error: {err}" + ); + + // empty segment + assert!( + apply_override_to_decoded_account(&mut decoded, "deposits..0", &serde_json::json!(1)) + .is_err() + ); + } + + /// The Scope template must default to the Main Market's prices account, since every price + /// recipe in the docs is written against its indices. + #[test] + fn test_kamino_scope_template_defaults_to_the_main_market() { + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-scope-price") + .expect("kamino-scope-price template should exist"); + assert_eq!( + template.address, + AccountAddress::Pubkey("3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH".to_string()) + ); + } + + /// These addresses are hardcoded facts about mainnet, so guard their shape and uniqueness. + /// A liveness check would need network access. + #[test] + fn test_named_kamino_reserve_templates_have_baked_addresses() { + use std::{collections::BTreeSet, str::FromStr}; + + use solana_pubkey::Pubkey; + + let registry = TemplateRegistry::new(); + + const NAMED: &[&str] = &["kamino-reserve-main-sol", "kamino-reserve-main-usdc"]; + + let mut addresses = BTreeSet::new(); + for id in NAMED { + let template = registry + .get(id) + .unwrap_or_else(|| panic!("named reserve template {} should exist", id)); + + assert_eq!( + template.account_type, "Reserve", + "{} should target a Reserve", + id + ); + + let surfpool_types::AccountAddress::Pubkey(address) = &template.address else { + panic!("{} should carry a plain pubkey address, not a PDA", id); + }; + assert!( + Pubkey::from_str(address).is_ok(), + "{} has an unparseable address: {}", + id, + address + ); + assert!( + addresses.insert(address.clone()), + "{} reuses an address already used by another named template", + id + ); + + let paths: Vec<&str> = template.property_paths(); + for required in [ + "config.liquidation_threshold_pct", + "liquidity.market_price_sf", + ] { + assert!( + paths.contains(&required), + "{} should expose {}", + id, + required + ); + } + + // Each must point at the template that moves its price, and name its Scope index - + // the lookup a user would otherwise do by hand. + let context = template.llm_context.as_deref().unwrap_or_default(); + assert!( + context.contains("kamino-scope-price"), + "{} should point at kamino-scope-price for moving its price", + id + ); + assert!( + context.contains("index"), + "{} should name the Scope index its price comes from", + id + ); + } + + assert_eq!( + addresses.len(), + NAMED.len(), + "all addresses must be distinct" + ); + } + + /// A path ending on an index must resolve to the array's ELEMENT type. Resolving it to the + /// array instead sends the value down the untyped conversion, where an all-hex base58 pubkey + /// such as the default one is mistaken for hex and panics the request. + #[test] + fn test_terminal_array_index_resolves_to_the_element_type() { + use anchor_lang_idl::types::IdlType; + + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-scope-price-source") + .expect("kamino-scope-price-source should exist"); + + for (path, expected) in [ + ("price_info_accounts.0", IdlType::Pubkey), + ("price_types.0", IdlType::U8), + ("ref_price.0", IdlType::U16), + ] { + let resolved = + surfpool_types::resolve_idl_type(&template.idl, &template.account_type, path) + .unwrap_or_else(|e| panic!("{path} should resolve: {e}")); + assert_eq!( + *resolved, expected, + "{path} should resolve to its element type, not the array" + ); + } + + // An index mid-path already worked; keep it that way. + let obligation = registry + .get("kamino-obligation-positions") + .expect("kamino-obligation-positions should exist"); + let resolved = surfpool_types::resolve_idl_type( + &obligation.idl, + &obligation.account_type, + "deposits.0.deposit_reserve", + ) + .expect("deposits.0.deposit_reserve should resolve"); + assert_eq!(*resolved, IdlType::Pubkey); + } + + /// Descriptions come from the IDL's own `docs`, or from an explicit `description` in the + /// YAML. Studio and any LLM reading a template rely on them. + #[test] + fn test_every_kamino_property_has_a_description() { + let registry = TemplateRegistry::new(); + let mut missing = Vec::new(); + let mut described = 0; + + for protocol in [ + "kamino", + "kamino-scope", + "kamino-farms", + "kamino-swap", + "kamino-vault", + "kamino-liquidity", + ] { + for template in registry.by_protocol(protocol) { + for property in &template.properties { + match property.description.as_deref() { + Some(text) if !text.trim().is_empty() => described += 1, + _ => missing.push(format!("{}:{}", template.id, property.path)), + } + } + } + } + + assert!( + missing.is_empty(), + "{} Kamino propert(ies) have no description ({} do):\n {}", + missing.len(), + described, + missing.join("\n ") + ); + } } diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index e7b7a6d24..248101b3d 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -7,6 +7,7 @@ use std::{ }; use agave_feature_set::FeatureSet; +use anchor_lang_idl::types::{IdlDefinedFields, IdlGenericArg, IdlType, IdlTypeDef, IdlTypeDefTy}; use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::Utc; use convert_case::Casing; @@ -159,50 +160,178 @@ pub fn apply_override_to_decoded_account( decoded_value: &mut Value, path: &str, value: &serde_json::Value, +) -> SurfpoolResult<()> { + let txtx_value = json_to_txtx_value(value)?; + set_decoded_account_value(decoded_value, path, txtx_value) +} + +/// Same as [`apply_override_to_decoded_account`], but takes an already-converted [`Value`]. +pub fn apply_typed_override_to_decoded_account( + decoded_value: &mut Value, + path: &str, + value: Value, +) -> SurfpoolResult<()> { + set_decoded_account_value(decoded_value, path, value) +} + +fn set_decoded_account_value( + decoded_value: &mut Value, + path: &str, + new_value: Value, ) -> SurfpoolResult<()> { let parts: Vec<&str> = path.split('.').collect(); - if parts.is_empty() { - return Err(SurfpoolError::internal("Empty path provided for override")); + if parts.iter().any(|part| part.is_empty()) { + return Err(SurfpoolError::internal(format!( + "Invalid path '{}' provided for override - contains an empty segment", + path + ))); } // Navigate to the parent of the target field let mut current = decoded_value; for part in &parts[..parts.len() - 1] { - match current { - Value::Object(map) => { - current = map.get_mut(&part.to_string()).ok_or_else(|| { + current = match current { + Value::Object(map) => map.get_mut(&part.to_string()).ok_or_else(|| { + SurfpoolError::internal(format!( + "Path segment '{}' not found in decoded account", + part + )) + })?, + Value::Array(items) => { + let index = parse_decoded_account_index(part, path)?; + let len = items.len(); + items.get_mut(index).ok_or_else(|| { SurfpoolError::internal(format!( - "Path segment '{}' not found in decoded account", - part + "Index {} is out of bounds for array of length {} in path '{}'", + index, len, path )) - })?; + })? } _ => { return Err(SurfpoolError::internal(format!( - "Cannot navigate through field '{}' - not an object", + "Cannot navigate through field '{}' - not an object or array", part ))); } - } + }; } - // Set the final field let final_key = parts[parts.len() - 1]; match current { Value::Object(map) => { - // Convert serde_json::Value to txtx Value - let txtx_value = json_to_txtx_value(value)?; - map.insert(final_key.to_string(), txtx_value); + map.insert(final_key.to_string(), new_value); + Ok(()) + } + Value::Array(items) => { + let index = parse_decoded_account_index(final_key, path)?; + let len = items.len(); + let slot = items.get_mut(index).ok_or_else(|| { + SurfpoolError::internal(format!( + "Index {} is out of bounds for array of length {} in path '{}'", + index, len, path + )) + })?; + *slot = new_value; Ok(()) } _ => Err(SurfpoolError::internal(format!( - "Cannot set field '{}' - parent is not an object", + "Cannot set field '{}' - parent is not an object or array", final_key ))), } } +fn parse_decoded_account_index(segment: &str, path: &str) -> SurfpoolResult { + segment.parse::().map_err(|_| { + SurfpoolError::internal(format!( + "Path segment '{}' in '{}' must be a zero-based array index", + segment, path + )) + }) +} + +fn json_integer_digits(json: &serde_json::Value, target: &str) -> SurfpoolResult { + match json { + serde_json::Value::Number(n) if n.as_u64().is_none() && n.as_i64().is_none() => { + Err(SurfpoolError::internal(format!( + "{n} exceeds what a JSON number can hold exactly; pass this {target} as a decimal \ + string instead, e.g. \"1152921504606846976000\"" + ))) + } + serde_json::Value::Number(n) => Ok(n.to_string()), + serde_json::Value::String(s) => Ok(s.trim().to_string()), + other => Err(SurfpoolError::internal(format!( + "Expected a number or decimal string for {target}, found {other}" + ))), + } +} + +/// Converts JSON into a txtx [`Value`] using the expected IDL type +fn json_to_txtx_value_for_idl_type( + json: &serde_json::Value, + idl_type: &IdlType, + idl_types: &[IdlTypeDef], +) -> SurfpoolResult { + match (idl_type, json) { + (IdlType::Pubkey, serde_json::Value::String(address)) => { + let pubkey = Pubkey::from_str(address).map_err(|e| { + SurfpoolError::internal(format!( + "Invalid pubkey '{}' in account override: {}", + address, e + )) + })?; + Ok(txtx_addon_network_svm_types::SvmValue::pubkey( + pubkey.to_bytes().to_vec(), + )) + } + (IdlType::Option(inner), _) if !json.is_null() => { + json_to_txtx_value_for_idl_type(json, inner, idl_types) + } + (IdlType::U128, _) => { + let digits = json_integer_digits(json, "u128")?; + let value = digits + .parse::() + .map_err(|e| SurfpoolError::internal(format!("Invalid u128 '{digits}': {e}")))?; + Ok(txtx_addon_network_svm_types::SvmValue::u128(value)) + } + (IdlType::I128, _) => { + let digits = json_integer_digits(json, "i128")?; + let value = digits + .parse::() + .map_err(|e| SurfpoolError::internal(format!("Invalid i128 '{digits}': {e}")))?; + Ok(txtx_addon_network_svm_types::SvmValue::i128(value)) + } + (IdlType::Vec(inner), serde_json::Value::Array(items)) + | (IdlType::Array(inner, _), serde_json::Value::Array(items)) => { + let converted = items + .iter() + .map(|item| json_to_txtx_value_for_idl_type(item, inner, idl_types)) + .collect::>>()?; + Ok(Value::Array(Box::new(converted))) + } + (IdlType::Defined { name, .. }, serde_json::Value::Object(fields)) => { + let Some(IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(named_fields)), + }) = idl_types.iter().find(|t| &t.name == name).map(|t| &t.ty) + else { + return json_to_txtx_value(json); + }; + + let mut object = IndexMap::new(); + for (key, value) in fields.iter() { + let converted = match named_fields.iter().find(|f| &f.name == key) { + Some(field) => json_to_txtx_value_for_idl_type(value, &field.ty, idl_types)?, + None => json_to_txtx_value(value)?, + }; + object.insert(key.clone(), converted); + } + Ok(Value::Object(object)) + } + _ => json_to_txtx_value(json), + } +} + /// Helper function to convert serde_json::Value to txtx Value fn json_to_txtx_value(json: &serde_json::Value) -> SurfpoolResult { match json { @@ -502,6 +631,18 @@ fn synthetic_blockhash_for_slot(slot: Slot, genesis_slot: Slot) -> SyntheticBloc SyntheticBlockhash::new(u64::MAX - (genesis_slot - slot - 1)) } +/// What one `fetch_before_use` attempt settled. Decides whether a persisted override keeps +/// asking on later slots, which it must while another attempt could still change the answer. +enum FetchOutcome { + Retired, + /// There is no remote to ask. Only a local account can satisfy the request. + NoRemote, + /// The remote has no such account. It may be created later. + NotOnRemote, + /// No answer was obtained. Another attempt may get one. + Unanswered, +} + impl SurfnetSvm { pub fn default() -> (Self, Receiver, Receiver) { Self::new(SurfnetSvmConfig::default()).unwrap() @@ -2655,7 +2796,25 @@ impl SurfnetSvm { target_slot ); - for override_instance in overrides { + let mut settled_this_slot: HashSet = HashSet::new(); + + // `take` already emptied the slot, so bailing out mid-loop would drop every override that + // has not been reached yet. Put the unprocessed tail back before returning the error. + let restore_unprocessed = |svm: &mut Self, from: usize| { + if let Err(e) = svm + .scheduled_overrides + .store(target_slot, overrides[from..].to_vec()) + { + error!( + "Failed to restore {} unprocessed override(s) for slot {}: {}", + overrides.len() - from, + target_slot, + e + ); + } + }; + + for (index, override_instance) in overrides.iter().enumerate() { if !override_instance.enabled { debug!("Skipping disabled override: {}", override_instance.id); continue; @@ -2692,45 +2851,80 @@ impl SurfnetSvm { override_instance.id, account_pubkey, override_instance.label ); + // Defaults to Retired: nothing was asked for, the account was already forked by an + // earlier override this slot, or there is no remote to ask. + let mut fetch_outcome = FetchOutcome::Retired; + // Fetch fresh account data from remote if requested - if override_instance.fetch_before_use { + if override_instance.fetch_before_use && !settled_this_slot.contains(&account_pubkey) { if let Some((client, _)) = remote_ctx { debug!( "Fetching fresh account data for {} from remote", account_pubkey ); - match client + let fetched = match client .get_account(&account_pubkey, CommitmentConfig::confirmed()) .await { Ok(GetAccountResult::FoundAccount(_pubkey, remote_account, _)) => { - debug!( - "Fetched account {} from remote: {} lamports, {} bytes", - account_pubkey, - remote_account.lamports(), - remote_account.data().len() - ); - - // Set the fresh account data in the SVM - if let Err(e) = self.inner.set_account(account_pubkey, remote_account) { - warn!( - "Failed to set account {} from remote: {}", - account_pubkey, e - ); - } + Some((remote_account, None)) } + Ok(GetAccountResult::FoundCoupledAccount( + (_pubkey, remote_account), + coupled, + _, + )) => Some(( + remote_account, + match coupled { + CoupledAccount::ProgramData(pubkey, account) + | CoupledAccount::Mint(pubkey, account) => { + account.map(|account| (pubkey, account)) + } + }, + )), Ok(GetAccountResult::None(_)) => { debug!("Account {} not found on remote", account_pubkey); - } - Ok(_) => { - debug!("Account {} fetched (other variant)", account_pubkey); + fetch_outcome = FetchOutcome::NotOnRemote; + None } Err(e) => { warn!( "Failed to fetch account {} from remote: {}", account_pubkey, e ); + fetch_outcome = FetchOutcome::Unanswered; + None + } + }; + + if let Some((remote_account, coupled)) = fetched { + debug!( + "Fetched account {} from remote: {} lamports, {} bytes", + account_pubkey, + remote_account.lamports(), + remote_account.data().len() + ); + + if let Some((coupled_pubkey, coupled_account)) = coupled { + if let Err(e) = self.inner.set_account(coupled_pubkey, coupled_account) + { + warn!( + "Failed to set coupled account {} from remote: {}", + coupled_pubkey, e + ); + } + } + + // Set the fresh account data in the SVM + if let Err(e) = self.inner.set_account(account_pubkey, remote_account) { + warn!( + "Failed to set account {} from remote: {}", + account_pubkey, e + ); + fetch_outcome = FetchOutcome::Unanswered; + } else { + settled_this_slot.insert(account_pubkey); } } } else { @@ -2738,6 +2932,33 @@ impl SurfnetSvm { "fetch_before_use enabled but no remote client available for override {}", override_instance.id ); + fetch_outcome = FetchOutcome::NoRemote; + } + } + + let existing_account = match self.inner.get_account(&account_pubkey) { + Ok(account) => account, + Err(e) => { + restore_unprocessed(self, index); + return Err(e); + } + }; + + // The request is only retired when another attempt could no longer change anything. + let fetch_retired = match fetch_outcome { + FetchOutcome::Retired => true, + FetchOutcome::Unanswered => false, + FetchOutcome::NoRemote | FetchOutcome::NotOnRemote => existing_account.is_some(), + }; + + if override_instance.persist { + let mut requeued = override_instance.clone(); + if requeued.fetch_before_use && fetch_retired { + requeued.fetch_before_use = false; + } + if let Err(e) = self.reschedule_override_for_next_slot(&requeued, target_slot) { + restore_unprocessed(self, index); + return Err(e); } } @@ -2769,7 +2990,7 @@ impl SurfnetSvm { ); // Get the account from the SVM - let Some(account) = self.inner.get_account(&account_pubkey)? else { + let Some(account) = existing_account else { warn!( "Account {} not found in SVM for override {}, skipping modifications", account_pubkey, override_instance.id @@ -2865,6 +3086,7 @@ impl SurfnetSvm { account_pubkey, override_instance.id ); + settled_this_slot.insert(account_pubkey); } } } @@ -2872,6 +3094,37 @@ impl SurfnetSvm { Ok(()) } + /// Re-queues `instance` for the slot after `target_slot`, replacing any copy of itself + /// already queued there. One entry per id, so an override cannot be applied twice to one slot. + fn reschedule_override_for_next_slot( + &mut self, + instance: &OverrideInstance, + target_slot: Slot, + ) -> SurfpoolResult<()> { + let next_slot = target_slot.checked_add(1).ok_or_else(|| { + SurfpoolError::internal(format!( + "Override {} cannot persist past slot {}: there is no next slot", + instance.id, target_slot + )) + })?; + let mut next = self + .scheduled_overrides + .get(&next_slot)? + .unwrap_or_default(); + + if let Some(existing) = next.iter_mut().find(|queued| { + queued.id == instance.id + && queued.account == instance.account + && queued.template_id == instance.template_id + }) { + *existing = instance.clone(); + } else { + next.push(instance.clone()); + } + self.scheduled_overrides.store(next_slot, next)?; + Ok(()) + } + /// Forges account data by applying overrides to existing account data /// /// This function: @@ -2962,12 +3215,15 @@ impl SurfnetSvm { // Apply overrides to the decoded value for (path, value) in overrides { - apply_override_to_decoded_account(&mut parsed_value, path, value)?; + let converted = match surfpool_types::resolve_idl_type(idl, &account_type.name, path) { + Ok(idl_type) => json_to_txtx_value_for_idl_type(value, idl_type, &idl.types)?, + Err(_) => json_to_txtx_value(value)?, + }; + apply_typed_override_to_decoded_account(&mut parsed_value, path, converted)?; } // Construct an IdlType::Defined that references the account type // This is needed because borsh_encode_value_to_idl_type expects IdlType, not IdlTypeDefTy - use anchor_lang_idl::types::{IdlGenericArg, IdlType}; let defined_type = IdlType::Defined { name: account_type.name.clone(), generics: account_type @@ -4157,7 +4413,13 @@ impl SurfnetSvm { // Schedule overrides by adding base slot to their scenario-relative slots for override_instance in scenario.overrides { let scenario_relative_slot = override_instance.scenario_relative_slot; - let absolute_slot = base_slot + scenario_relative_slot; + // Both operands are caller-supplied, so the sum has to be checked. + let absolute_slot = base_slot.checked_add(scenario_relative_slot).ok_or_else(|| { + SurfpoolError::internal(format!( + "Override {} cannot be scheduled: base slot {} plus relative slot {} overflows", + override_instance.id, base_slot, scenario_relative_slot + )) + })?; debug!( "Scheduling override at absolute slot {} (base {} + relative {})", @@ -4166,9 +4428,7 @@ impl SurfnetSvm { let mut slot_overrides = self .scheduled_overrides - .get(&absolute_slot) - .ok() - .flatten() + .get(&absolute_slot)? .unwrap_or_default(); slot_overrides.push(override_instance); self.scheduled_overrides @@ -7070,4 +7330,671 @@ mod tests { .expect("Valid account should be restored"); assert_eq!(restored_account.lamports, 1_000_000); } + + /// `Obligation.unhealthy_borrow_value_sf` (u128), counting the discriminator. + const UNHEALTHY_OFFSET: usize = 2256; + + /// A zeroed Kamino `Obligation` owned by klend. `SurfnetSvm::default()` already registers + /// the bundled template IDLs, so klend's is resolvable by owner program. + fn scheduled_persist_fixture( + persist: bool, + ) -> (SurfnetSvm, Pubkey, surfpool_types::OverrideInstance) { + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + + let klend = Pubkey::from_str_const("KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD"); + let idl: Idl = serde_json::from_str(crate::scenarios::registry::KAMINO_V1_IDL_CONTENT) + .expect("kamino idl"); + let obligation_disc = &idl + .accounts + .iter() + .find(|a| a.name == "Obligation") + .expect("Obligation account") + .discriminator; + + let mut data = vec![0u8; 3344]; + data[..8].copy_from_slice(obligation_disc); + + let account_pubkey = Pubkey::new_unique(); + surfnet_svm + .inner + .set_account( + account_pubkey, + Account { + lamports: 1_000_000, + data, + owner: klend, + executable: false, + rent_epoch: 0, + }, + ) + .expect("set obligation account"); + + let mut instance = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + instance.persist = persist; + + (surfnet_svm, account_pubkey, instance) + } + + #[tokio::test] + async fn test_persisted_override_is_rescheduled_for_the_next_slot() { + const SLOT: u64 = 500; + + let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); + let instance_id = instance.id.clone(); + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + let unhealthy = u128::from_le_bytes( + account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] + .try_into() + .expect("16 bytes"), + ); + assert_eq!(unhealthy, 1_234, "override should have been applied"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!( + next.len(), + 1, + "exactly one override queued for the next slot" + ); + assert_eq!(next[0].id, instance_id); + assert!(next[0].persist, "persist flag must survive rescheduling"); + + assert!( + svm.scheduled_overrides + .get(&SLOT) + .expect("storage read") + .is_none(), + "materialized slot should be drained" + ); + } + + #[tokio::test] + async fn test_persisted_override_stops_refetching_once_the_account_is_forked() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, mut instance) = scheduled_persist_fixture(true); + instance.fetch_before_use = true; + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!(next.len(), 1, "one entry per override id"); + assert!(next[0].persist, "persist must survive rescheduling"); + assert!( + !next[0].fetch_before_use, + "the account is forked, so later slots must not re-fetch it and discard local writes" + ); + } + + /// An override that writes no account fields still forks the account, so it must stop fetching too. + #[tokio::test] + async fn test_persisted_override_that_writes_no_fields_stops_refetching() { + const SLOT: u64 = 500; + + let (mut svm, account_pubkey, _instance) = scheduled_persist_fixture(true); + + // Values consumed entirely by PDA derivation, so `account_values` filters down to empty. + let seed_only = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pda { + program_id: "KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD".to_string(), + seeds: vec![surfpool_types::PdaSeed::PropertyRef("market".to_string())], + }, + ) + .with_values(HashMap::from([( + "market".to_string(), + serde_json::json!(account_pubkey.to_string()), + )])); + + // Point the derived address at a real forked account so presence is what is being tested. + let derived = seed_only + .account + .resolve(Some(&seed_only.values)) + .expect("derive pda"); + let forked = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("fixture account present"); + svm.inner + .set_account(derived, forked) + .expect("set derived account"); + + let mut no_values = surfpool_types::OverrideInstance::new( + "kamino-obligation-noop".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ); + no_values.persist = true; + no_values.fetch_before_use = true; + + let mut seed_only = seed_only; + seed_only.persist = true; + seed_only.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![seed_only, no_values]) + .expect("schedule overrides"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!(next.len(), 2, "both overrides re-armed, one entry each"); + for queued in &next { + assert!(queued.persist, "persist must survive rescheduling"); + assert!( + !queued.fetch_before_use, + "override {} forked its account, so later slots must not re-fetch it", + queued.id + ); + } + } + + /// Minimal JSON-RPC stand-in that answers every request with one canned `result` body, so + /// the remote-fetch branches can be exercised without a network. + async fn canned_rpc(result_json: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind canned rpc"); + let addr = listener.local_addr().expect("local addr"); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = vec![0u8; 16 * 1024]; + let _ = stream.read(&mut buf).await; + let body = format!(r#"{{"jsonrpc":"2.0","result":{result_json},"id":1}}"#); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; + }); + } + }); + + format!("http://{addr}") + } + + /// The remote having no such account is only an answer about this slot - accounts get created + /// later. While there is nothing local to work on, a persisted override must keep asking, or + /// it stays inert for the rest of the run. + #[tokio::test] + async fn test_persisted_override_retries_while_the_account_is_not_on_remote() { + const SLOT: u64 = 500; + const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; + + let url = canned_rpc(NULL_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + // An address the SVM has never seen, so there is no local account to fall back on. + let mut absent = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + absent.persist = true; + absent.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![absent]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(remote), SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!(next.len(), 1, "one entry per override id"); + assert!( + next[0].fetch_before_use, + "the account may appear later, so the next slot must keep asking for it" + ); + } + + /// The mirror case: the remote has nothing but a local account already exists, so the override + /// can work. Asking again would only risk overwriting that local account once the address is + /// populated upstream. + #[tokio::test] + async fn test_persisted_override_stops_asking_when_only_a_local_account_exists() { + const SLOT: u64 = 500; + const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; + + let url = canned_rpc(NULL_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + let (mut svm, _account_pubkey, mut instance) = scheduled_persist_fixture(true); + instance.fetch_before_use = true; + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(remote), SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!(next.len(), 1, "one entry per override id"); + assert!( + !next[0].fetch_before_use, + "the local account is usable, so later fetches must not overwrite it" + ); + } + + /// Token and executable accounts return `FoundCoupledAccount`. That arm used to fall through + /// a catch-all that logged and dropped the account, so the fetch reported success while the + /// target was never forked - every later write then failed with "not found in SVM". + #[tokio::test] + async fn test_fetch_before_use_materializes_a_coupled_account() { + const SLOT: u64 = 500; + // A 165-byte SPL token account (state = Initialized), which sends `get_account` down the + // coupled-mint path. The canned server answers the mint lookup with the same body. + const TOKEN_ACCOUNT: &str = concat!( + r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":{"data":[""#, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + r#"","base64"],"executable":false,"lamports":2039280,"#, + r#""owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":165}}"# + ); + + let url = canned_rpc(TOKEN_ACCOUNT).await; + let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + let target = Pubkey::new_unique(); + let mut instance = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(target.to_string()), + ); + instance.fetch_before_use = true; + + assert!( + svm.inner + .get_account(&target) + .expect("get_account") + .is_none(), + "the target must start absent so the fetch is what materializes it" + ); + + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(remote), SLOT) + .await + .expect("materialize"); + + let forked = svm + .inner + .get_account(&target) + .expect("get_account") + .expect("the coupled account must be written into the SVM"); + assert_eq!(forked.data.len(), 165, "the token account data was forked"); + assert_eq!(forked.lamports, 2_039_280, "lamports came from the remote"); + } + + /// With no remote client there is nothing to fetch from, but the request is still unmet while + /// the account is absent. `materialize_overrides_for_slot` is public, so a caller can pass a + /// client on a later slot - retiring the flag here would permanently disable that. + #[tokio::test] + async fn test_persisted_override_keeps_asking_when_absent_and_no_remote() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); + + let mut absent = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), + ) + .with_values(HashMap::from([( + "unhealthy_borrow_value_sf".to_string(), + serde_json::json!(1_234u64), + )])); + absent.persist = true; + absent.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![absent]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!(next.len(), 1, "one entry per override id"); + assert!( + next[0].fetch_before_use, + "the request is still unmet, so it must not be retired" + ); + } + + #[tokio::test] + async fn test_slot_overflow_is_an_error_not_a_wrap() { + let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); + + assert!( + svm.reschedule_override_for_next_slot(&instance, u64::MAX) + .is_err(), + "there is no slot after u64::MAX" + ); + + let mut far = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 10, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ); + far.scenario_relative_slot = 10; + let scenario = surfpool_types::Scenario { + id: "overflow".to_string(), + name: "overflow".to_string(), + description: String::new(), + tags: vec![], + overrides: vec![far], + }; + + assert!( + svm.register_scenario(scenario, Some(u64::MAX - 1)).is_err(), + "base slot plus relative slot overflows and must be rejected" + ); + } + + /// A transient RPC failure must not be mistaken for a satisfied fetch. The account already + /// being present locally is not enough - the override asked for fresh data and did not get it, + /// so with `persist` the flag has to survive or it pins stale data for the rest of the run. + #[tokio::test] + async fn test_persisted_override_retries_after_a_failed_fetch() { + const SLOT: u64 = 500; + + // Unroutable port: the fetch fails without touching the network. + let unreachable = ( + SurfnetRemoteClient::new("http://127.0.0.1:1"), + CommitmentConfig::confirmed(), + ); + + let (mut svm, _account_pubkey, mut instance) = scheduled_persist_fixture(true); + instance.fetch_before_use = true; + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&Some(unreachable), SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot should have queued overrides"); + assert_eq!(next.len(), 1, "one entry per override id"); + assert!(next[0].persist, "persist must survive rescheduling"); + assert!( + next[0].fetch_before_use, + "the fetch failed, so the next slot must retry it instead of pinning stale data" + ); + } + + /// Guards the ordering invariant only. The re-fetch that used to clobber the first override + /// needs a remote client, so `remote_ctx: &None` cannot reproduce it here - that path is + /// covered against a live fork. + #[tokio::test] + async fn test_two_fetching_overrides_on_one_account_both_apply() { + const SLOT: u64 = 500; + // immediately precedes unhealthy_borrow_value_sf in the Obligation layout + const ALLOWED_OFFSET: usize = UNHEALTHY_OFFSET - 16; + + let (mut svm, account_pubkey, first) = scheduled_persist_fixture(false); + let mut first = first; + first.fetch_before_use = true; + + let mut second = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), + ) + .with_values(HashMap::from([( + "allowed_borrow_value_sf".to_string(), + serde_json::json!(5_678u64), + )])); + second.fetch_before_use = true; + + svm.scheduled_overrides + .store(SLOT, vec![first, second]) + .expect("schedule overrides"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + let read = |off: usize| { + u128::from_le_bytes(account.data[off..off + 16].try_into().expect("16 bytes")) + }; + assert_eq!( + read(UNHEALTHY_OFFSET), + 1_234, + "the first override must survive the second override's fetch" + ); + assert_eq!( + read(ALLOWED_OFFSET), + 5_678, + "the second override must apply" + ); + } + + /// Two persistent overrides that share a caller-supplied id but target different accounts must both survive re-arming. + #[tokio::test] + async fn test_reschedule_keeps_overrides_sharing_an_id_across_accounts() { + const SLOT: u64 = 500; + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + + let first_account = Pubkey::new_unique(); + let second_account = Pubkey::new_unique(); + + let mut first = surfpool_types::OverrideInstance::new( + "kamino-obligation-health".to_string(), + 0, + surfpool_types::AccountAddress::Pubkey(first_account.to_string()), + ); + // The collision this guards against: a hand-written scenario reusing a plain id. + first.id = "ov-1".to_string(); + first.persist = true; + + let mut second = first.clone(); + second.account = surfpool_types::AccountAddress::Pubkey(second_account.to_string()); + + surfnet_svm + .reschedule_override_for_next_slot(&first, SLOT) + .expect("reschedule"); + surfnet_svm + .reschedule_override_for_next_slot(&second, SLOT) + .expect("reschedule"); + + let queued = surfnet_svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("read scheduled overrides") + .expect("overrides queued for the next slot"); + assert_eq!( + queued.len(), + 2, + "two overrides on different accounts share the id 'ov-1'; keying only on the id drops \ + one of them, so a scenario silently stops being applied" + ); + + surfnet_svm + .reschedule_override_for_next_slot(&first, SLOT) + .expect("reschedule"); + let queued = surfnet_svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("read scheduled overrides") + .expect("overrides queued for the next slot"); + assert_eq!( + queued.len(), + 2, + "re-arming an override must replace its own queued copy, not append a duplicate" + ); + } + + #[tokio::test] + async fn test_non_persisted_override_is_not_rescheduled() { + const SLOT: u64 = 500; + + let (mut svm, _account_pubkey, instance) = scheduled_persist_fixture(false); + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + assert!( + svm.scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .is_none(), + "a one-shot override must not be rescheduled" + ); + } + + #[tokio::test] + async fn test_persisted_override_survives_a_run_of_slots() { + const FIRST_SLOT: u64 = 900; + const SLOTS: u64 = 5; + + let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); + svm.scheduled_overrides + .store(FIRST_SLOT, vec![instance]) + .expect("schedule override"); + + for slot in FIRST_SLOT..FIRST_SLOT + SLOTS { + // Clobber the field, the way `refresh_obligation` would. + let mut account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] + .copy_from_slice(&0u128.to_le_bytes()); + svm.inner + .set_account(account_pubkey, account) + .expect("clobber account"); + + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + + let account = svm + .inner + .get_account(&account_pubkey) + .expect("get_account") + .expect("account present"); + let unhealthy = u128::from_le_bytes( + account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] + .try_into() + .expect("16 bytes"), + ); + assert_eq!( + unhealthy, 1_234, + "persisted override should be re-applied on slot {slot} after being clobbered" + ); + } + } + + #[tokio::test] + async fn test_persisted_override_does_not_duplicate_itself() { + const SLOT: u64 = 700; + + let (mut svm, _account_pubkey, instance) = scheduled_persist_fixture(true); + svm.scheduled_overrides + .store(SLOT + 1, vec![instance.clone()]) + .expect("pre-queue next slot"); + svm.scheduled_overrides + .store(SLOT, vec![instance]) + .expect("schedule override"); + + svm.materialize_overrides_for_slot(&None, SLOT) + .await + .expect("materialize"); + + let next = svm + .scheduled_overrides + .get(&(SLOT + 1)) + .expect("storage read") + .expect("next slot queue"); + assert_eq!( + next.len(), + 1, + "override must not be queued twice for one slot" + ); + } } diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs new file mode 100644 index 000000000..b915f29be --- /dev/null +++ b/crates/core/src/tests/kamino/mod.rs @@ -0,0 +1,753 @@ +//! Kamino integration tests. +//! +//! These fetch the real accounts from mainnet rather than embedding captured copies, so they need +//! a network connection and are compiled only behind a feature: +//! +//! ```text +//! cargo test -p surfpool-core --features integration-tests kamino +//! ``` +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. +//! +//! What these cover that the unit tests cannot: a synthetic account is built *by* the bundled IDL, +//! so it can never disagree with it. Real accounts carry non-zero padding, live enum +//! discriminants and populated arrays, so an IDL that has drifted from the on-chain layout shows +//! up as a byte diff here and nowhere else. + +use std::collections::HashMap; + +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{GetAccountResult, 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 RESERVE: &str = "14sqx2pLioXamoBFxE6CvHNth6uEAvJhXuJ2iwZMccAS"; +const OBLIGATION: &str = "3iprSGrEQdBxhmqV399tYQQPG8Z1Hh2aYFrBwgqFXjGS"; +const SCOPE_PRICES: &str = "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C"; +const FARM_STATE: &str = "18DizwAbBuuNGwfav3v6yWMbunnye4RnMLwLp67jAtj"; +const SWAP_ORDER: &str = "14Buhfy7WBpiv2e6RMZNN5R7w3ua8MY1ZJ3WQyd29uJ"; +const STRATEGY: &str = "1EXN5b1z7wucGb2uZoQmqjHdPoK1PNfUNWuwq8AqLTV"; +const LENDING_MARKET: &str = "13iJ9S8qW8VGG94qUapfe3zbjvfig8PPgbDyfgHY6UHL"; +const ORACLE_MAPPINGS: &str = "4zh6bmb77qX2CL7t5AJYCqa6YqFafbz3QJNeFvZjLowg"; +const ORACLE_TWAPS: &str = "6L6vUts9tYqxHVUCEFVc2mzZw6yxMn8C6a44cp5ga7e9"; +const FARMS_USER_STATE: &str = "1142jwhL6evoo2Ziqe6FJaj49USXA4JNXHcMH9bUFHz"; +const FARMS_GLOBAL_CONFIG: &str = "3UQ2HX2VtY2tuVycTEintP3SSkbH5UkNes3QkG577iYz"; +const SWAP_GLOBAL_CONFIG: &str = "3Lvo5giazx2Gyz9a2WWmDWj6eFeugKkcKSNK3qrPu46Y"; +const VAULT_STATE: &str = "2BEYDYJFQWHkfVHrA4r9fPnfBm1nguqmgoMBfzrWnBDP"; +const VAULT_WHITELIST_ENTRY: &str = "2GYjQAagrcmWDYZAjkeMZsDuT7jDyuiVqjxXuKvHEtcm"; + +/// Fetches the accounts in one request, so every account returned is from the same slot. +async fn fetch(addresses: &[&str]) -> Vec> { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let pubkeys: Vec = addresses + .iter() + .map(|a| Pubkey::from_str_const(a)) + .collect(); + + client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + .unwrap_or_else(|e| panic!("failed to fetch {addresses:?} from mainnet: {e}")) + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account.data, + GetAccountResult::None(_) => { + panic!("{address} no longer exists on mainnet; the test needs a new address") + } + }) + .collect() +} + +/// Byte indices at which two buffers differ. +fn diff_indices(a: &[u8], b: &[u8]) -> Vec { + a.iter() + .zip(b.iter()) + .enumerate() + .filter(|(_, (x, y))| x != y) + .map(|(i, _)| i) + .collect() +} + +/// A failure here means a bundled IDL disagrees with the live on-chain layout. +#[tokio::test] +async fn real_mainnet_accounts_round_trip_unchanged() { + let cases: &[(&str, &str, &str)] = &[ + ("kamino-reserve-config", "Reserve", RESERVE), + ("kamino-obligation-health", "Obligation", OBLIGATION), + ("kamino-scope-price", "OraclePrices", SCOPE_PRICES), + ("kamino-farms-reward-accumulator", "FarmState", FARM_STATE), + ("kamino-swap-order", "Order", SWAP_ORDER), + ( + "kamino-liquidity-strategy-balances", + "WhirlpoolStrategy", + STRATEGY, + ), + ]; + + let addresses: Vec<&str> = cases.iter().map(|(_, _, a)| *a).collect(); + let accounts = fetch(&addresses).await; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + for ((template_id, account_name, _), data) in cases.iter().zip(&accounts) { + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("template {template_id} should exist")); + + let account_def = template + .idl + .accounts + .iter() + .find(|a| a.name == *account_name) + .unwrap_or_else(|| panic!("{account_name} not in the IDL")); + assert_eq!( + &data[..8], + account_def.discriminator.as_slice(), + "{account_name} discriminator does not match the IDL - wrong account type?" + ); + + let forged = surfnet_svm + .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .unwrap_or_else(|e| { + panic!( + "live mainnet {account_name} failed to decode/re-encode with the bundled \ + IDL: {e}" + ) + }); + + assert_eq!( + forged.len(), + data.len(), + "{account_name} changed size on round-trip" + ); + let diffs = diff_indices(&forged, data); + assert!( + diffs.is_empty(), + "live mainnet {} was altered by a no-op round-trip at {} byte(s), first at {:?}", + account_name, + diffs.len(), + diffs.first() + ); + } +} + +/// Catches collateral damage from the Borsh re-encode against real padding and live enum +/// discriminants, which a synthetic account cannot exercise. +#[tokio::test] +async fn override_on_real_account_touches_only_target_bytes() { + let accounts = fetch(&[RESERVE, SCOPE_PRICES]).await; + let (reserve_data, scope_data) = (&accounts[0], &accounts[1]); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + // Reserve: one u8 at a known offset. + const LIQ_THRESHOLD_PCT: usize = 4873; + let reserve = registry.get("kamino-reserve-config").unwrap(); + let original_threshold = reserve_data[LIQ_THRESHOLD_PCT]; + assert!( + original_threshold > 50, + "the live reserve should start above the value we set, got {original_threshold}" + ); + + let forged = surfnet_svm + .get_forged_account_data( + &pubkey, + reserve_data, + &reserve.idl, + &HashMap::from([( + "config.liquidation_threshold_pct".to_string(), + serde_json::json!(50u8), + )]), + ) + .expect("threshold override on live reserve"); + + assert_eq!( + diff_indices(&forged, reserve_data), + vec![LIQ_THRESHOLD_PCT], + "exactly one byte should change, and only the liquidation threshold" + ); + assert_eq!(forged[LIQ_THRESHOLD_PCT], 50); + + // Scope: one u64 inside a 512-element array. + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const IDX: usize = 0; + let scope = registry.get("kamino-scope-price").unwrap(); + let value_off = PRICES_BASE + IDX * DATED_PRICE_SIZE; + + let original_value = + u64::from_le_bytes(scope_data[value_off..value_off + 8].try_into().unwrap()); + assert!( + original_value > 0, + "live Scope index {IDX} should be populated, got {original_value}" + ); + let new_value = original_value / 2; + + let forged = surfnet_svm + .get_forged_account_data( + &pubkey, + scope_data, + &scope.idl, + &HashMap::from([( + format!("prices.{IDX}.price.value"), + serde_json::json!(new_value), + )]), + ) + .expect("price override on live Scope account"); + + let diffs = diff_indices(&forged, scope_data); + assert!(!diffs.is_empty(), "the price should have changed"); + assert!( + diffs.iter().all(|i| (value_off..value_off + 8).contains(i)), + "only the 8 bytes of prices[{IDX}].price.value should change, got {diffs:?}" + ); + assert_eq!( + u64::from_le_bytes(forged[value_off..value_off + 8].try_into().unwrap()), + new_value + ); + + let next = PRICES_BASE + DATED_PRICE_SIZE; + assert_eq!( + &forged[next..next + DATED_PRICE_SIZE], + &scope_data[next..next + DATED_PRICE_SIZE], + "neighbouring Scope entry must not move" + ); +} + +/// Evidence that a Reserve's cached price is derived from Scope, which is why +/// `kamino-scope-price` is the durable lever rather than the Reserve's own cache. Only checkable +/// against a genuine pair - constructing both sides would test our arithmetic against itself. +#[tokio::test] +async fn reserve_price_is_derived_from_scope() { + // Reserve offsets incl. discriminator. + const MARKET_PRICE_SF: usize = 248; // u128 scaled fraction (value << 60) + const SCOPE_PRICE_FEED: usize = 5112; + const SCOPE_PRICE_CHAIN: usize = 5144; // [u16; 4], 65535 = unused + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const UNUSED_CHAIN_ENTRY: u16 = 65535; + + let accounts = fetch(&[RESERVE, SCOPE_PRICES]).await; + let (reserve_data, scope_data) = (&accounts[0], &accounts[1]); + + let scope_account = Pubkey::from_str_const(SCOPE_PRICES); + assert_eq!( + &reserve_data[SCOPE_PRICE_FEED..SCOPE_PRICE_FEED + 32], + scope_account.as_ref(), + "the reserve must price through the Scope account this test fetches" + ); + + let chain: Vec = (0..4) + .map(|i| { + let off = SCOPE_PRICE_CHAIN + i * 2; + u16::from_le_bytes(reserve_data[off..off + 2].try_into().unwrap()) + }) + .take_while(|entry| *entry != UNUSED_CHAIN_ENTRY) + .collect(); + assert!( + !chain.is_empty(), + "the reserve should name at least one Scope index" + ); + + // A chained price is the product of its entries, each value / 10^exp. + let mut scope_price = 1.0f64; + for index in &chain { + let base = PRICES_BASE + (*index as usize) * DATED_PRICE_SIZE; + let value = u64::from_le_bytes(scope_data[base..base + 8].try_into().unwrap()); + let exp = u64::from_le_bytes(scope_data[base + 8..base + 16].try_into().unwrap()); + assert!( + value > 0 && exp < 30, + "Scope entry {index} looks unpopulated (value {value}, exp {exp})" + ); + scope_price *= value as f64 / 10f64.powi(exp as i32); + } + + let cached_sf = u128::from_le_bytes( + reserve_data[MARKET_PRICE_SF..MARKET_PRICE_SF + 16] + .try_into() + .unwrap(), + ); + let cached_price = cached_sf as f64 / 2f64.powi(60); + assert!(cached_price > 0.0, "the reserve should have a cached price"); + + // The cache is only rewritten when someone calls refresh_reserve, so it lags Scope by however + // long it has been since the last refresh. The tolerance covers that lag; what is being tested + // is the interpretation (value << 60, the chain being a product, the offsets), which a wrong + // reading would miss by orders of magnitude rather than a few percent. + let relative_error = (scope_price - cached_price).abs() / cached_price; + assert!( + relative_error < 0.05, + "reserve cached price ${cached_price} should track the Scope chain {chain:?} product \ + ${scope_price} - if these have diverged, either the scaled-fraction interpretation \ + (value << 60), the price_chain semantics (a product), or an offset is wrong. \ + Relative error {relative_error}" + ); +} + +/// A valid JSON value for a scalar IDL type, or `None` for composites. Mirrors the helper in +/// the registry unit tests; duplicated rather than widening that module's visibility. +fn sample_scalar_value(ty: &anchor_lang_idl::types::IdlType) -> Option { + use anchor_lang_idl::types::IdlType; + match ty { + IdlType::Bool => Some(serde_json::json!(true)), + IdlType::U8 | IdlType::U16 | IdlType::U32 | IdlType::U64 | IdlType::U128 => { + Some(serde_json::json!(7u64)) + } + IdlType::I8 | IdlType::I16 | IdlType::I32 | IdlType::I64 | IdlType::I128 => { + Some(serde_json::json!(7i64)) + } + IdlType::Pubkey => Some(serde_json::json!( + "So11111111111111111111111111111111111111112" + )), + _ => None, + } +} + +/// Every account type our templates target that has a live instance on mainnet. `WithdrawTicket` +/// is absent: the feature is new in klend 1.23.0 and none existed when this was written. +const LIVE_ACCOUNTS: &[(&str, &str, &str)] = &[ + ("kamino", "Reserve", RESERVE), + ("kamino", "Obligation", OBLIGATION), + ("kamino", "LendingMarket", LENDING_MARKET), + ("kamino-scope", "OraclePrices", SCOPE_PRICES), + ("kamino-scope", "OracleMappings", ORACLE_MAPPINGS), + ("kamino-scope", "OracleTwaps", ORACLE_TWAPS), + ("kamino-farms", "FarmState", FARM_STATE), + ("kamino-farms", "UserState", FARMS_USER_STATE), + ("kamino-farms", "GlobalConfig", FARMS_GLOBAL_CONFIG), + ("kamino-swap", "Order", SWAP_ORDER), + ("kamino-swap", "GlobalConfig", SWAP_GLOBAL_CONFIG), + ("kamino-vault", "VaultState", VAULT_STATE), + ("kamino-vault", "ReserveWhitelistEntry", VAULT_WHITELIST_ENTRY), + ("kamino-liquidity", "WhirlpoolStrategy", STRATEGY), +]; + +/// Every template, exercised against a live instance of the account it targets: an identity +/// round-trip must not alter bytes, then writing every scalar it advertises must change some. +#[tokio::test] +async fn every_template_round_trips_over_a_live_account() { + let addresses: Vec<&str> = LIVE_ACCOUNTS.iter().map(|(_, _, a)| *a).collect(); + let fetched = fetch(&addresses).await; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + let mut checked = 0; + + for ((protocol, account_type, address), data) in LIVE_ACCOUNTS.iter().zip(&fetched) { + for template in registry + .by_protocol(protocol) + .into_iter() + .filter(|t| t.account_type == *account_type) + { + let identity = surfnet_svm + .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .unwrap_or_else(|e| { + panic!("identity round-trip failed for {} ({address}): {e}", template.id) + }); + // A live account may be allocated larger than the struct needs, so the re-encode is + // a prefix rather than the whole buffer. + assert!( + identity.len() <= data.len(), + "{} re-encoded larger than the live account", + template.id + ); + assert_eq!( + identity, + data[..identity.len()], + "identity round-trip changed bytes for {} ({address})", + template.id + ); + + let mut overrides: HashMap = HashMap::new(); + for property in &template.properties { + let ty = surfpool_types::resolve_idl_type( + &template.idl, + &template.account_type, + &property.path, + ) + .unwrap_or_else(|e| panic!("[{}] {}: {e}", template.id, property.path)); + if let Some(value) = sample_scalar_value(ty) { + overrides.insert(property.path.clone(), value); + } + } + if overrides.is_empty() { + continue; // composite-only template; its llm_context documents the full shape + } + + let forged = surfnet_svm + .get_forged_account_data(&pubkey, data, &template.idl, &overrides) + .unwrap_or_else(|e| { + panic!( + "forge failed for {} with {} scalar override(s): {e}", + template.id, + overrides.len() + ) + }); + assert_eq!( + forged.len(), + identity.len(), + "forged size changed for {}", + template.id + ); + assert_ne!( + forged, identity, + "overrides for {} did not change any bytes", + template.id + ); + checked += 1; + } + } + + assert!( + checked >= 25, + "expected to exercise at least 25 Kamino templates against live accounts, got {checked}" + ); +} + +/// The default pubkey "1111...1111" is all hex characters, which the encoder used to misread as +/// hex bytes and panic on. +#[tokio::test] +async fn obligation_array_index_and_pubkey_overrides() { + // Obligation offsets incl. discriminator: header is 88 bytes, then 136 per deposit. + const DEPOSIT_0_RESERVE: usize = 8 + 88; + const DEPOSIT_0_AMOUNT: usize = DEPOSIT_0_RESERVE + 32; + const DEPOSIT_1_RESERVE: usize = 8 + 88 + 136; + + let data = fetch(&[OBLIGATION]).await.remove(0); + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-obligation-positions") + .expect("kamino-obligation-positions template should exist"); + + let wsol = "So11111111111111111111111111111111111111112"; + let overrides: HashMap = HashMap::from([ + ( + "deposits.0.deposit_reserve".to_string(), + serde_json::json!("11111111111111111111111111111111"), + ), + ( + "deposits.0.deposited_amount".to_string(), + serde_json::json!(4_200_000_000u64), + ), + ( + "deposits.1.deposit_reserve".to_string(), + serde_json::json!(wsol), + ), + ("has_debt".to_string(), serde_json::json!(1)), + ]); + + let forged = surfnet_svm + .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .expect("array-index and pubkey overrides should apply"); + + assert_eq!(forged.len(), data.len(), "account size must be preserved"); + assert_eq!( + &forged[DEPOSIT_0_RESERVE..DEPOSIT_0_RESERVE + 32], + Pubkey::default().as_ref(), + "deposits[0].deposit_reserve should be the default pubkey" + ); + assert_eq!( + u64::from_le_bytes( + forged[DEPOSIT_0_AMOUNT..DEPOSIT_0_AMOUNT + 8] + .try_into() + .unwrap() + ), + 4_200_000_000u64, + "deposits[0].deposited_amount should be written at its array index" + ); + assert_eq!( + &forged[DEPOSIT_1_RESERVE..DEPOSIT_1_RESERVE + 32], + Pubkey::from_str_const(wsol).as_ref(), + "deposits[1].deposit_reserve should be the wSOL mint" + ); +} + +#[tokio::test] +async fn scope_price_override_writes_expected_bytes() { + // OraclePrices: discriminator + oracle_mappings pubkey, then 56 bytes per entry. + const PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + const SOL_INDEX: usize = 0; + // $125.50 with exp = 8 + const SOL_VALUE: u64 = 12_550_000_000; + const SOL_EXP: u64 = 8; + const AT_SLOT: u64 = 370_000_000; + const AT_TS: u64 = 1_800_000_000; + + let data = fetch(&[SCOPE_PRICES]).await.remove(0); + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let template = registry + .get("kamino-scope-price") + .expect("kamino-scope-price template should exist"); + + let overrides: HashMap = HashMap::from([ + ( + format!("prices.{SOL_INDEX}.price.value"), + serde_json::json!(SOL_VALUE), + ), + ( + format!("prices.{SOL_INDEX}.price.exp"), + serde_json::json!(SOL_EXP), + ), + ( + format!("prices.{SOL_INDEX}.last_updated_slot"), + serde_json::json!(AT_SLOT), + ), + ( + format!("prices.{SOL_INDEX}.unix_timestamp"), + serde_json::json!(AT_TS), + ), + ]); + + let forged = surfnet_svm + .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .expect("scope price override should apply"); + + assert_eq!(forged.len(), data.len(), "account size must be preserved"); + + let base = PRICES_BASE + SOL_INDEX * DATED_PRICE_SIZE; + let read = |off: usize| u64::from_le_bytes(forged[off..off + 8].try_into().unwrap()); + assert_eq!(read(base), SOL_VALUE, "price.value"); + assert_eq!(read(base + 8), SOL_EXP, "price.exp"); + assert_eq!(read(base + 16), AT_SLOT, "last_updated_slot"); + assert_eq!(read(base + 24), AT_TS, "unix_timestamp"); + + // price = value / 10^exp + assert_eq!(SOL_VALUE as f64 / 10f64.powi(SOL_EXP as i32), 125.50); + + // The neighbouring entry is populated on a live account, so require it unchanged rather + // than zero. + let next = PRICES_BASE + (SOL_INDEX + 1) * DATED_PRICE_SIZE; + assert_eq!( + &forged[next..next + DATED_PRICE_SIZE], + &data[next..next + DATED_PRICE_SIZE], + "writing one price index must not disturb the next entry" + ); +} + +/// A reward accrues from the gap between the farm accumulator and the user's tally, so both +/// halves must be writable. +#[tokio::test] +async fn farms_reward_override_writes_both_halves() { + let fetched = fetch(&[FARM_STATE, FARMS_USER_STATE]).await; + let (farm_data, user_data) = (&fetched[0], &fetched[1]); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + let farm = registry + .get("kamino-farms-reward-accumulator") + .expect("kamino-farms-reward-accumulator template"); + let farm_overrides: HashMap = HashMap::from([ + ( + "reward_infos.0.reward_per_share_scaled".to_string(), + serde_json::json!(5_000_000u64), + ), + ( + "total_active_stake_scaled".to_string(), + serde_json::json!(1_000_000u64), + ), + ]); + let forged_farm = surfnet_svm + .get_forged_account_data(&pubkey, farm_data, &farm.idl, &farm_overrides) + .expect("farm accumulator override should apply"); + assert_eq!(forged_farm.len(), farm_data.len()); + assert_ne!(&forged_farm, farm_data); + + // UserState offsets incl. discriminator: 80-byte header, then the [u128; 10] tally. + const TALLY_0: usize = 88; + const UNCLAIMED_0: usize = TALLY_0 + 160; + + let user = registry + .get("kamino-farms-user-rewards") + .expect("kamino-farms-user-rewards template"); + let user_overrides: HashMap = HashMap::from([ + ( + "rewards_issued_unclaimed.0".to_string(), + serde_json::json!(777_000u64), + ), + ("rewards_tally_scaled.0".to_string(), serde_json::json!(0u64)), + ("active_stake_scaled".to_string(), serde_json::json!(1_000u64)), + ]); + let forged_user = surfnet_svm + .get_forged_account_data(&pubkey, user_data, &user.idl, &user_overrides) + .expect("user reward override should apply"); + + assert_eq!(forged_user.len(), user_data.len()); + assert_eq!( + u64::from_le_bytes( + forged_user[UNCLAIMED_0..UNCLAIMED_0 + 8] + .try_into() + .unwrap() + ), + 777_000u64, + "rewards_issued_unclaimed[0] should be written at its array index" + ); +} + +/// The two overrides that survive `refresh_obligation`: crash the Scope price, then tighten the +/// deposit reserve's liquidation threshold. +#[tokio::test] +async fn liquidation_setup_writes_durable_inputs() { + const LTV_PCT: usize = 4872; + const LIQ_THRESHOLD_PCT: usize = 4873; + const SCOPE_PRICES_BASE: usize = 8 + 32; + const DATED_PRICE_SIZE: usize = 56; + + let fetched = fetch(&[SCOPE_PRICES, RESERVE]).await; + let (scope_data, reserve_data) = (&fetched[0], &fetched[1]); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + // Crash the Scope price the reserve prices from. + const IDX: usize = 45; + const CRASHED: u64 = 15_000_000; + let scope = registry.get("kamino-scope-price").expect("scope template"); + let scope_overrides: HashMap = HashMap::from([ + ( + format!("prices.{IDX}.price.value"), + serde_json::json!(CRASHED), + ), + (format!("prices.{IDX}.price.exp"), serde_json::json!(8u64)), + ]); + let forged_scope = surfnet_svm + .get_forged_account_data(&pubkey, scope_data, &scope.idl, &scope_overrides) + .expect("scope crash should apply"); + + let off = SCOPE_PRICES_BASE + IDX * DATED_PRICE_SIZE; + assert_eq!( + u64::from_le_bytes(forged_scope[off..off + 8].try_into().unwrap()), + CRASHED, + "crashed price must land at the Scope entry the reserve names" + ); + assert_eq!( + CRASHED as f64 / 10f64.powi(8), + 0.15, + "value/exp must decode to $0.15" + ); + + // Tighten the live reserve's liquidation threshold, leaving its loan-to-value alone. + let reserve = registry + .get("kamino-reserve-config") + .expect("reserve config template"); + let live_ltv = reserve_data[LTV_PCT]; + let reserve_overrides: HashMap = HashMap::from([ + ( + "config.liquidation_threshold_pct".to_string(), + serde_json::json!(50u8), + ), + ( + "config.max_liquidation_bonus_bps".to_string(), + serde_json::json!(1000u16), + ), + ]); + let forged_reserve = surfnet_svm + .get_forged_account_data(&pubkey, reserve_data, &reserve.idl, &reserve_overrides) + .expect("reserve config override should apply"); + + assert_eq!( + forged_reserve[LIQ_THRESHOLD_PCT], 50, + "liquidation threshold must be lowered" + ); + assert_eq!( + forged_reserve[LTV_PCT], live_ltv, + "loan-to-value must be left untouched, so a position above the new 50% liquidation \ + threshold becomes liquidatable" + ); + assert_eq!( + forged_reserve.len(), + reserve_data.len(), + "reserve size must be preserved" + ); +} + +/// A ticket becomes redeemable once the reserve's queue cursor reaches its sequence number. The +/// ticket half is synthetic because no `WithdrawTicket` exists on mainnet yet; the reserve half +/// uses a live account. +#[tokio::test] +async fn withdraw_ticket_and_queue_cursor() { + let reserve_data = fetch(&[RESERVE]).await.remove(0); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let pubkey = Pubkey::new_unique(); + + let ticket = registry + .get("kamino-withdraw-ticket") + .expect("withdraw ticket template"); + let ticket_disc = &ticket + .idl + .accounts + .iter() + .find(|a| a.name == "WithdrawTicket") + .expect("WithdrawTicket") + .discriminator; + let mut ticket_data = vec![0u8; 520]; + ticket_data[..8].copy_from_slice(ticket_disc); + + let ticket_overrides: HashMap = HashMap::from([ + ("sequence_number".to_string(), serde_json::json!(7u64)), + ( + "queued_collateral_amount".to_string(), + serde_json::json!(500u64), + ), + ("invalid".to_string(), serde_json::json!(0u8)), + ]); + let forged_ticket = surfnet_svm + .get_forged_account_data(&pubkey, &ticket_data, &ticket.idl, &ticket_overrides) + .expect("withdraw ticket override should apply"); + assert_eq!( + u64::from_le_bytes(forged_ticket[8..16].try_into().unwrap()), + 7, + "ticket sequence number" + ); + + // Advance the live reserve's cursor to 7, making ticket 7 serveable. + let limits = registry + .get("kamino-reserve-limits") + .expect("reserve limits template"); + let queue_overrides: HashMap = HashMap::from([ + ( + "withdraw_queue.queued_collateral_amount".to_string(), + serde_json::json!(500u64), + ), + ( + "withdraw_queue.next_withdrawable_ticket_sequence_number".to_string(), + serde_json::json!(7u64), + ), + ( + "withdraw_queue.next_issued_ticket_sequence_number".to_string(), + serde_json::json!(8u64), + ), + ( + "liquidity.total_available_amount".to_string(), + serde_json::json!(0u64), + ), + ]); + let forged_reserve = surfnet_svm + .get_forged_account_data(&pubkey, &reserve_data, &limits.idl, &queue_overrides) + .expect("withdraw queue override should apply"); + + assert_eq!(forged_reserve.len(), reserve_data.len()); + assert_ne!(forged_reserve, reserve_data); +} diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index 01bc99f08..56573e72a 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,4 +1,6 @@ pub mod helpers; pub mod integration; +#[cfg(feature = "integration-tests")] +pub mod kamino; pub mod plugin; pub mod simnet_events; diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts index 348ea2ae5..80a87f241 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts @@ -35,6 +35,10 @@ enabled: boolean, * Whether to fetch fresh account data just before transaction execution */ fetchBeforeUse?: boolean, +/** + * Whether to re-apply this override on every subsequent slot, rather than only once + */ +persist?: boolean, /** * Account address to override - use pubkey for known addresses or pda for derived addresses */ diff --git a/crates/types/src/rpc_endpoints.json b/crates/types/src/rpc_endpoints.json index d863d035a..740e1e8a3 100644 --- a/crates/types/src/rpc_endpoints.json +++ b/crates/types/src/rpc_endpoints.json @@ -861,6 +861,7 @@ "label": "Option (An optional label for this override instance)", "enabled": "bool (Indicates whether this override instance is enabled)", "fetchBeforeUse": "bool (Indicates whether to fetch the latest on-chain account data before applying overrides)", + "persist": "bool (Optional, defaults to false. If true, re-applies this override on every following slot instead of only one, which is needed when something else writes the account in between. Use it only for values no transaction writes - an oracle price, a disabled switch, a risk parameter - never for state the transactions under test mutate: re-applying reverts their writes at the start of the next slot, so a pool would refill itself after every swap)", "account": "AccountAddress (The account this override targets, as {\"pubkey\": \"\"} or {\"pda\": {\"programId\": \"\", \"seeds\": [ ... ]}})" } } diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index fb3859572..7df74404a 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -500,6 +500,13 @@ pub struct OverrideInstance { #[serde(default)] #[cfg_attr(feature = "ts-bindings", ts(as = "Option", optional))] pub fetch_before_use: bool, + /// Whether to re-apply this override on every subsequent slot, rather than only once + #[schemars( + description = "If true, re-applies this override every following slot. Use only for values no transaction writes: it reverts transaction writes to the same fields." + )] + #[serde(default)] + #[cfg_attr(feature = "ts-bindings", ts(as = "Option", optional))] + pub persist: bool, /// Account address to override - use pubkey for known addresses or pda for derived addresses #[schemars( description = "Account address: either {\"pubkey\": \"base58_address\"} or {\"pda\": {\"programId\": \"...\", \"seeds\": [...]}}" @@ -517,6 +524,7 @@ impl OverrideInstance { label: None, enabled: true, fetch_before_use: false, + persist: false, account, } } @@ -530,6 +538,11 @@ impl OverrideInstance { self.label = Some(label); self } + + pub fn with_persist(mut self, persist: bool) -> Self { + self.persist = persist; + self + } } /// A scenario containing a timeline of overrides @@ -931,6 +944,117 @@ pub struct YamlOverrideTemplateEntry { pub llm_context: Option, } +/// Walks a dot-notation path: struct fields by name, array elements by index. +/// +/// Returns the last named field and the type at the path's end. They differ on a trailing index: +/// `price_info_accounts.0` is documented by the array but its value is one Pubkey. +fn resolve_idl_path<'a>( + idl: &'a Idl, + account_type: &str, + path: &str, +) -> Result< + ( + &'a anchor_lang_idl::types::IdlField, + &'a anchor_lang_idl::types::IdlType, + ), + String, +> { + use anchor_lang_idl::types::{IdlDefinedFields, IdlType, IdlTypeDefTy}; + + fn named_fields<'a>( + idl: &'a Idl, + type_name: &str, + ) -> Result<&'a Vec, String> { + let def = idl + .types + .iter() + .find(|t| t.name == type_name) + .ok_or_else(|| format!("type '{}' not found in IDL types", type_name))?; + match &def.ty { + IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(fields)), + } => Ok(fields), + _ => Err(format!("'{}' is not a struct with named fields", type_name)), + } + } + + let mut segments = path.split('.'); + let first = segments + .next() + .ok_or_else(|| format!("empty property path for '{}'", account_type))?; + let mut field = named_fields(idl, account_type)? + .iter() + .find(|f| f.name == first) + .ok_or_else(|| format!("field '{}' not found in '{}'", first, account_type))?; + let mut ty: &IdlType = &field.ty; + + for segment in segments { + match ty { + // An index descends into the element type while `field` stays on the array, + // which is what documents it. + IdlType::Array(inner, _) | IdlType::Vec(inner) => { + segment.parse::().map_err(|_| { + format!("'{}' is an array; '{}' is not an index", path, segment) + })?; + ty = inner.as_ref(); + } + IdlType::Defined { name, .. } => { + field = named_fields(idl, name)? + .iter() + .find(|f| f.name == segment) + .ok_or_else(|| format!("field '{}' not found in type '{}'", segment, name))?; + ty = &field.ty; + } + other => { + return Err(format!( + "cannot descend into '{}': leaf type {:?} has no fields", + segment, other + )); + } + } + } + + Ok((field, ty)) +} + +/// The IDL type of the value a property path writes. For a path ending on an index this is the +/// array's element type, not the array - the conversion needs the element to encode it. +pub fn resolve_idl_type<'a>( + idl: &'a Idl, + account_type: &str, + path: &str, +) -> Result<&'a anchor_lang_idl::types::IdlType, String> { + resolve_idl_path(idl, account_type, path).map(|(_, ty)| ty) +} + +fn idl_field_docs(idl: &Idl, account_type: &str, path: &str) -> Option { + // The containing field, deliberately: an array element carries no docs of its own. + let docs = &resolve_idl_path(idl, account_type, path).ok()?.0.docs; + if docs.is_empty() { + return None; + } + Some(docs.join(" ")) +} + +/// Fills in each property's `description` from the IDL's own `docs` when the template did not +/// supply one, so field guidance is not written twice. +fn describe_properties_from_idl( + properties: Vec, + idl: &Idl, + account_type: &str, +) -> Vec { + properties + .into_iter() + .map(|yaml| { + let mut property: Property = yaml.into(); + if property.description.is_none() { + property.description = idl_field_docs(idl, account_type, &property.path); + } + property + }) + .collect() +} + impl YamlOverrideTemplateCollection { /// Convert collection to runtime OverrideTemplates with loaded IDL pub fn to_override_templates(self, idl: Idl) -> Vec { @@ -945,20 +1069,23 @@ impl YamlOverrideTemplateCollection { self.templates .into_iter() - .map(|entry| OverrideTemplate { - id: entry.id, - name: entry.name, - description: entry.description, - protocol: self.protocol.clone(), - idl: idl.clone(), - address: entry.address.into(), - account_type: entry + .map(|entry| { + let account_type = entry .idl_account_name - .unwrap_or_else(|| default_account_type.clone()), - properties: entry.properties.into_iter().map(Into::into).collect(), - constants: constants.clone(), - tags: self.tags.clone(), - llm_context: entry.llm_context, + .unwrap_or_else(|| default_account_type.clone()); + OverrideTemplate { + id: entry.id, + name: entry.name, + description: entry.description, + protocol: self.protocol.clone(), + idl: idl.clone(), + address: entry.address.into(), + properties: describe_properties_from_idl(entry.properties, &idl, &account_type), + account_type, + constants: constants.clone(), + tags: self.tags.clone(), + llm_context: entry.llm_context, + } }) .collect() } From 13eb39cf959f3ebbdcaa58572ed6659fd15fb877 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 9 Sep 2026 16:46:01 +0300 Subject: [PATCH 02/20] feat(scenarios): add reusable raw-layout overrides --- crates/core/src/scenarios/README.md | 23 ++ crates/core/src/scenarios/registry.rs | 85 ++++- crates/core/src/surfnet/svm.rs | 67 +++- crates/core/src/tests/kamino/mod.rs | 32 +- crates/core/src/tests/pump/mod.rs | 21 +- crates/types/src/scenarios.rs | 480 +++++++++++++++++++++++++- 6 files changed, 665 insertions(+), 43 deletions(-) diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 70e080af2..6a53395d5 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -56,6 +56,29 @@ cargo test -p surfpool-core --features integration-tests kamino Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint instead of the public one. The default test run needs no network. +### Programs with no IDL + +Programs that publish no usable IDL can describe their account bytes directly in an override +collection: + +```yaml +raw_layout: + account_size: 128 + magic: { offset: 0, bytes: [69, 88, 65, 77, 80, 76, 69] } + +templates: + - id: example-market-price + properties: + - path: price + offset: 32 + encoding: u128 +``` + +Raw-layout overrides use the same scenario API as IDL-backed templates. Values are encoded as +little-endian integers and can be written once or repeatedly at a fixed stride. Account-size and +magic guards ensure a template refuses the wrong account before changing any bytes. Large integers +that exceed `u64` should be supplied as decimal strings so JSON parsing cannot lose precision. + ### Override Templates Directly using the `surfnet_registerScenario` endpoint requires building out a map of account keys that are specific to the schema of the account that is being written to. This is a cumbersome process in most cases. diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 217ac8039..11f419e19 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -189,7 +189,21 @@ impl TemplateRegistry { Ok(idl) => idl, Err(e) => panic!("unable to load {} idl: {}", protocol_name, e), }; + self.load_collection(Some(idl), overrides_content, protocol_name); + } + + /// For programs that publish no IDL. Their templates must carry a `raw_layout` and spell out + /// every property description, since there is no schema to fall back on. + pub fn load_raw_layout_overrides(&mut self, overrides_content: &str, protocol_name: &str) { + self.load_collection(None, overrides_content, protocol_name); + } + fn load_collection( + &mut self, + idl: Option, + overrides_content: &str, + protocol_name: &str, + ) { let collection = match serde_yaml::from_str::(overrides_content) { Ok(c) => c, @@ -557,6 +571,49 @@ mod tests { assert!(registry.contains("pump-amm-global-config")); } + #[test] + fn raw_layout_collection_loads_without_an_idl() { + const OVERRIDES: &str = r#" +protocol: Example +version: v1 +account_type: State +raw_layout: + account_size: 16 + magic: + offset: 0 + bytes: [69, 88] +templates: + - id: example-raw-value + name: Override Value + description: Override one integer in an example binary account + address: + type: pubkey + value: "11111111111111111111111111111111" + properties: + - path: value + offset: 8 + encoding: u64 + label: Value + description: Example unsigned integer +"#; + + let mut registry = TemplateRegistry::default(); + registry.load_raw_layout_overrides(OVERRIDES, "example"); + + let template = registry.get("example-raw-value").expect("raw template"); + assert!(template.idl.is_none()); + let layout = template.raw_layout.as_ref().expect("raw layout"); + let output = layout + .materialize( + &[69, 88, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + &template.properties, + &HashMap::from([("value".to_string(), serde_json::json!(42))]), + 0, + ) + .expect("materialize raw template"); + assert_eq!(u64::from_le_bytes(output[8..16].try_into().unwrap()), 42); + } + #[test] fn test_jupiter_template_loads_correctly() { let registry = TemplateRegistry::new(); @@ -717,7 +774,7 @@ mod tests { let registry = TemplateRegistry::new(); let jupiter_template = registry.get("jupiter-token-ledger-override").unwrap(); let has_token_ledger = jupiter_template - .idl + .idl() .accounts .iter() .any(|acc| acc.name == "TokenLedger"); @@ -1157,18 +1214,24 @@ mod tests { let registry = TemplateRegistry::new(); let mut errors = Vec::new(); + let mut checked = 0usize; for template in registry.all() { + // Templates for programs that publish no IDL declare their own byte offsets, so there + // is no schema for their paths to resolve against. Raw encoding and guarded writes are + // covered by the unit tests on `RawLayout`. + let Some(idl) = template.idl.as_ref() else { + continue; + }; for property in &template.properties { // constant_ref properties are UI dropdowns (e.g. token pickers), not // account fields, so they are not expected to resolve against the IDL. if property.is_constant_ref() { continue; } - if let Err(e) = surfpool_types::resolve_idl_type( - &template.idl, - &template.account_type, - &property.path, - ) { + checked += 1; + if let Err(e) = + surfpool_types::resolve_idl_type(idl, &template.account_type, &property.path) + { errors.push(format!("[{}] {}: {}", template.id, property.path, e)); } } @@ -1180,6 +1243,12 @@ mod tests { errors.len(), errors.join("\n ") ); + // Without this the skip above could silently swallow every template and the test would pass + // having resolved nothing. + assert!( + checked > 0, + "no property was resolved against an IDL, so this proved nothing" + ); } #[test] @@ -1339,7 +1408,7 @@ mod tests { ("ref_price.0", IdlType::U16), ] { let resolved = - surfpool_types::resolve_idl_type(&template.idl, &template.account_type, path) + surfpool_types::resolve_idl_type(template.idl(), &template.account_type, path) .unwrap_or_else(|e| panic!("{path} should resolve: {e}")); assert_eq!( *resolved, expected, @@ -1352,7 +1421,7 @@ mod tests { .get("kamino-obligation-positions") .expect("kamino-obligation-positions should exist"); let resolved = surfpool_types::resolve_idl_type( - &obligation.idl, + obligation.idl(), &obligation.account_type, "deposits.0.deposit_reserve", ) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index baff46c76..831432580 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -285,6 +285,13 @@ fn json_integer_digits(json: &serde_json::Value, target: &str) -> SurfpoolResult } } +/// The bundled template registry, parsed once and reused. +fn template_registry() -> &'static crate::scenarios::TemplateRegistry { + static REGISTRY: std::sync::OnceLock = + std::sync::OnceLock::new(); + REGISTRY.get_or_init(crate::scenarios::TemplateRegistry::new) +} + /// Converts JSON into a txtx [`Value`] using the expected IDL type fn json_to_txtx_value_for_idl_type( json: &serde_json::Value, @@ -841,7 +848,11 @@ impl SurfnetSvm { fn register_builtin_template_idls(&mut self) { let registry = TemplateRegistry::new(); for (_, template) in registry.templates.into_iter() { - let _ = self.register_idl(template.idl, None); + // Templates for programs with no IDL have nothing to register; they write through + // `raw_layout` instead. + if let Some(idl) = template.idl { + let _ = self.register_idl(idl, None); + } } } @@ -3065,6 +3076,50 @@ impl SurfnetSvm { continue; }; + // Programs with no usable IDL carry a byte layout instead, and this MUST come + // before the IDL lookup below: those programs have no registered IDL at all, so the + // lookup would `continue` and silently drop the override. + let raw_template = template_registry() + .get(&override_instance.template_id) + .filter(|t| t.raw_layout.is_some()) + .cloned(); + if let Some(template) = raw_template { + let raw_layout = template.raw_layout.expect("filtered above"); + let properties = template.properties; + match raw_layout.materialize( + account.data(), + &properties, + &account_values, + target_slot, + ) { + Ok(new_data) => { + let modified = Account { + lamports: account.lamports(), + data: new_data, + owner: *account.owner(), + executable: account.executable(), + rent_epoch: account.rent_epoch(), + }; + if let Err(e) = self.inner.set_account(account_pubkey, modified) { + warn!("Failed to set raw-layout account {}: {}", account_pubkey, e); + } else { + debug!( + "Raw-layout override {} applied {} field(s) to {}", + override_instance.id, + account_values.len(), + account_pubkey + ); + settled_this_slot.insert(account_pubkey); + } + } + Err(e) => warn!( + "Raw-layout override {} failed on {}: {}", + override_instance.id, account_pubkey, e + ), + } + continue; + } + // Mints fail the token unpack and keep flowing through the IDL path. if is_supported_token_program(account.owner()) { if let Ok(token_account) = TokenAccount::unpack(account.data()) { @@ -5809,10 +5864,18 @@ mod tests { assert!(!epoch_schedule.warmup); let registry = TemplateRegistry::new(); + let mut checked = 0usize; for (_, template) in registry.templates { - let program_id = template.idl.address.clone(); + // Templates for programs that publish no IDL have nothing to register. + let Some(idl) = template.idl else { continue }; + let program_id = idl.address.clone(); assert!(svm.registered_idls.get(&program_id).unwrap().is_some()); + checked += 1; } + assert!( + checked > 0, + "no template carried an IDL, so this proved nothing about registration" + ); assert!(svm.skip_blockhash_check); } diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs index 9bdc0c1a5..aef2ba25e 100644 --- a/crates/core/src/tests/kamino/mod.rs +++ b/crates/core/src/tests/kamino/mod.rs @@ -107,7 +107,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { .unwrap_or_else(|| panic!("template {template_id} should exist")); let account_def = template - .idl + .idl() .accounts .iter() .find(|a| a.name == *account_name) @@ -119,7 +119,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { ); let forged = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, data, template.idl(), &HashMap::new()) .unwrap_or_else(|e| { panic!( "live mainnet {account_name} failed to decode/re-encode with the bundled \ @@ -167,7 +167,7 @@ async fn override_on_real_account_touches_only_target_bytes() { .get_forged_account_data( &pubkey, reserve_data, - &reserve.idl, + reserve.idl(), &HashMap::from([( "config.liquidation_threshold_pct".to_string(), serde_json::json!(50u8), @@ -201,7 +201,7 @@ async fn override_on_real_account_touches_only_target_bytes() { .get_forged_account_data( &pubkey, scope_data, - &scope.idl, + scope.idl(), &HashMap::from([( format!("prices.{IDX}.price.value"), serde_json::json!(new_value), @@ -359,7 +359,7 @@ async fn every_template_round_trips_over_a_live_account() { .filter(|t| t.account_type == *account_type) { let identity = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, data, template.idl(), &HashMap::new()) .unwrap_or_else(|e| { panic!( "identity round-trip failed for {} ({address}): {e}", @@ -383,7 +383,7 @@ async fn every_template_round_trips_over_a_live_account() { let mut overrides: HashMap = HashMap::new(); for property in &template.properties { let ty = surfpool_types::resolve_idl_type( - &template.idl, + template.idl(), &template.account_type, &property.path, ) @@ -397,7 +397,7 @@ async fn every_template_round_trips_over_a_live_account() { } let forged = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &overrides) + .get_forged_account_data(&pubkey, data, template.idl(), &overrides) .unwrap_or_else(|e| { panic!( "forge failed for {} with {} scalar override(s): {e}", @@ -460,7 +460,7 @@ async fn obligation_array_index_and_pubkey_overrides() { ]); let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .get_forged_account_data(&Pubkey::new_unique(), &data, template.idl(), &overrides) .expect("array-index and pubkey overrides should apply"); assert_eq!(forged.len(), data.len(), "account size must be preserved"); @@ -524,7 +524,7 @@ async fn scope_price_override_writes_expected_bytes() { ]); let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .get_forged_account_data(&Pubkey::new_unique(), &data, template.idl(), &overrides) .expect("scope price override should apply"); assert_eq!(forged.len(), data.len(), "account size must be preserved"); @@ -574,7 +574,7 @@ async fn farms_reward_override_writes_both_halves() { ), ]); let forged_farm = surfnet_svm - .get_forged_account_data(&pubkey, farm_data, &farm.idl, &farm_overrides) + .get_forged_account_data(&pubkey, farm_data, farm.idl(), &farm_overrides) .expect("farm accumulator override should apply"); assert_eq!(forged_farm.len(), farm_data.len()); assert_ne!(&forged_farm, farm_data); @@ -601,7 +601,7 @@ async fn farms_reward_override_writes_both_halves() { ), ]); let forged_user = surfnet_svm - .get_forged_account_data(&pubkey, user_data, &user.idl, &user_overrides) + .get_forged_account_data(&pubkey, user_data, user.idl(), &user_overrides) .expect("user reward override should apply"); assert_eq!(forged_user.len(), user_data.len()); @@ -644,7 +644,7 @@ async fn liquidation_setup_writes_durable_inputs() { (format!("prices.{IDX}.price.exp"), serde_json::json!(8u64)), ]); let forged_scope = surfnet_svm - .get_forged_account_data(&pubkey, scope_data, &scope.idl, &scope_overrides) + .get_forged_account_data(&pubkey, scope_data, scope.idl(), &scope_overrides) .expect("scope crash should apply"); let off = SCOPE_PRICES_BASE + IDX * DATED_PRICE_SIZE; @@ -675,7 +675,7 @@ async fn liquidation_setup_writes_durable_inputs() { ), ]); let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, reserve_data, &reserve.idl, &reserve_overrides) + .get_forged_account_data(&pubkey, reserve_data, reserve.idl(), &reserve_overrides) .expect("reserve config override should apply"); assert_eq!( @@ -709,7 +709,7 @@ async fn withdraw_ticket_and_queue_cursor() { .get("kamino-withdraw-ticket") .expect("withdraw ticket template"); let ticket_disc = &ticket - .idl + .idl() .accounts .iter() .find(|a| a.name == "WithdrawTicket") @@ -727,7 +727,7 @@ async fn withdraw_ticket_and_queue_cursor() { ("invalid".to_string(), serde_json::json!(0u8)), ]); let forged_ticket = surfnet_svm - .get_forged_account_data(&pubkey, &ticket_data, &ticket.idl, &ticket_overrides) + .get_forged_account_data(&pubkey, &ticket_data, ticket.idl(), &ticket_overrides) .expect("withdraw ticket override should apply"); assert_eq!( u64::from_le_bytes(forged_ticket[8..16].try_into().unwrap()), @@ -758,7 +758,7 @@ async fn withdraw_ticket_and_queue_cursor() { ), ]); let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, &reserve_data, &limits.idl, &queue_overrides) + .get_forged_account_data(&pubkey, &reserve_data, limits.idl(), &queue_overrides) .expect("withdraw queue override should apply"); assert_eq!(forged_reserve.len(), reserve_data.len()); diff --git a/crates/core/src/tests/pump/mod.rs b/crates/core/src/tests/pump/mod.rs index 960ce8e2f..650662766 100644 --- a/crates/core/src/tests/pump/mod.rs +++ b/crates/core/src/tests/pump/mod.rs @@ -157,9 +157,12 @@ async fn real_mainnet_accounts_round_trip_unchanged() { let template = registry .get(template_id) .unwrap_or_else(|| panic!("template {template_id} should exist")); - - let account_def = template + let idl = template .idl + .as_ref() + .unwrap_or_else(|| panic!("Pump template {template_id} must carry an IDL")); + + let account_def = idl .accounts .iter() .find(|a| a.name == *account_name) @@ -171,7 +174,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { ); let forged = surfnet_svm - .get_forged_account_data(&pubkey, &account.data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, &account.data, idl, &HashMap::new()) .unwrap_or_else(|e| { panic!( "live mainnet {account_name} {address} failed to decode/re-encode with the \ @@ -226,8 +229,12 @@ async fn override_on_real_account_touches_only_target_bytes() { ), ("complete".to_string(), serde_json::json!(true)), ]); + let curve_idl = curve + .idl + .as_ref() + .expect("Pump curve template must carry an IDL"); let forged = surfnet_svm - .get_forged_account_data(&pubkey, curve_data, &curve.idl, &overrides) + .get_forged_account_data(&pubkey, curve_data, curve_idl, &overrides) .expect("curve override on the live bonding curve"); assert_eq!( forged.len(), @@ -265,8 +272,12 @@ async fn override_on_real_account_touches_only_target_bytes() { serde_json::json!(5_000_000_000i64), ), ]); + let pool_idl = pool + .idl + .as_ref() + .expect("Pump AMM template must carry an IDL"); let forged = surfnet_svm - .get_forged_account_data(&pubkey, pool_data, &pool.idl, &overrides) + .get_forged_account_data(&pubkey, pool_data, pool_idl, &overrides) .expect("pool override on the live canonical pool"); assert_eq!( forged.len(), diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index a04ca80a1..0e25a7a4a 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -277,6 +277,12 @@ pub struct Property { /// For constant_ref type: the name of the constant definition to use #[serde(default, skip_serializing_if = "Option::is_none")] pub constant: Option, + /// Raw-layout only: byte offset of this field within the account. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Raw-layout only: how this field's bytes are produced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encoding: Option, } impl Property { @@ -288,6 +294,8 @@ impl Property { label: None, description: None, constant: None, + offset: None, + encoding: None, } } @@ -299,6 +307,8 @@ impl Property { label: None, description: None, constant: Some(constant.into()), + offset: None, + encoding: None, } } @@ -383,8 +393,11 @@ pub struct OverrideTemplate { pub description: String, /// Protocol this template is for (e.g., "Pyth", "Switchboard") pub protocol: String, - /// IDL for the account structure - defines all available fields and types - pub idl: Idl, + /// IDL for the account structure - defines all available fields and types. + /// + /// `None` for programs that publish no IDL and are written through `raw_layout` instead. Those + /// templates cannot use the IDL write path at all, so there is nothing to reconstruct here. + pub idl: Option, /// How to determine the account address pub address: AccountAddress, /// Account type name from the IDL (e.g., "PriceAccount") @@ -401,9 +414,27 @@ pub struct OverrideTemplate { /// This helps LLMs understand how to correctly use the template #[serde(default, skip_serializing_if = "Option::is_none")] pub llm_context: Option, + /// Set for programs with no usable IDL. When present the override engine writes bytes at + /// each property's offset instead of decoding and re-encoding through the IDL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_layout: Option, } impl OverrideTemplate { + /// The IDL this template was built from. + /// + /// Panics for templates that have none - those belong to programs that publish no IDL and are + /// written through `raw_layout`. Callers that may legitimately see either must match on the + /// field instead of calling this. + pub fn idl(&self) -> &Idl { + self.idl.as_ref().unwrap_or_else(|| { + panic!( + "template {} has no IDL; it is written through raw_layout", + self.id + ) + }) + } + pub fn new( id: String, name: String, @@ -419,13 +450,14 @@ impl OverrideTemplate { name, description, protocol, - idl, + idl: Some(idl), address, account_type, properties, constants: HashMap::new(), tags: Vec::new(), llm_context: None, + raw_layout: None, } } @@ -642,7 +674,8 @@ pub struct YamlOverrideTemplateFile { pub properties: Vec, #[serde(default)] pub constants: HashMap, - pub idl_file_path: String, + #[serde(default)] + pub idl_file_path: Option, pub address: YamlAccountAddress, #[serde(default)] pub tags: Vec, @@ -659,7 +692,7 @@ impl YamlOverrideTemplateFile { name: self.name, description: self.description, protocol: self.protocol, - idl, + idl: Some(idl), address: self.address.into(), account_type: self.account_type, properties: self.properties.into_iter().map(Into::into).collect(), @@ -670,6 +703,7 @@ impl YamlOverrideTemplateFile { .collect(), tags: self.tags, llm_context: self.llm_context, + raw_layout: None, } } } @@ -850,6 +884,12 @@ pub enum YamlProperty { /// For constant_ref type: the name of the constant definition to use #[serde(default)] constant: Option, + /// Raw-layout only: byte offset of this field within the account + #[serde(default)] + offset: Option, + /// Raw-layout only: how this field's bytes are produced + #[serde(default)] + encoding: Option, }, } @@ -863,6 +903,8 @@ impl From for Property { label, description, constant, + offset, + encoding, } => { let kind = match kind.as_deref() { Some("constant_ref") => PropertyKind::ConstantRef, @@ -874,6 +916,8 @@ impl From for Property { label, description, constant, + offset, + encoding, } } } @@ -924,14 +968,18 @@ pub struct YamlOverrideTemplateCollection { /// Account type name from the IDL (optional, can be overridden per template) #[serde(default)] pub account_type: Option, - /// Path to shared IDL file - pub idl_file_path: String, + /// Path to shared IDL file. Absent for programs that publish no IDL. + #[serde(default)] + pub idl_file_path: Option, /// Common tags for all templates #[serde(default)] pub tags: Vec, /// Protocol-specific constants shared by all templates in this collection #[serde(default)] pub constants: HashMap, + /// Byte layout, for programs with no usable IDL. Shared by every template in the collection. + #[serde(default)] + pub raw_layout: Option, /// The templates pub templates: Vec, } @@ -954,6 +1002,242 @@ pub struct YamlOverrideTemplateEntry { pub llm_context: Option, } +// ======================================== +// Raw byte layouts (programs with no usable IDL) +// ======================================== + +/// How a raw-layout field's bytes are produced. Every variant is integer-exact: values arrive as +/// JSON integers or decimal strings and are written little-endian, never routed through f64. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub enum RawEncoding { + U8, + U16, + U32, + U64, + U128, + I32, + I64, + I128, + /// A signed 32-bit value written to `count` slots, `stride` bytes apart. + /// + /// Exists because some layouts repeat one logical setting across a run of fixed-size records, and + /// exposing one property per record means exposing several that must agree - a worse footgun than + /// whatever it was meant to fix, such as a quote ladder repeated across fixed-size records. + I32Strided { + count: usize, + stride: usize, + }, + /// A base58 pubkey, written as 32 bytes. + Bytes32, + /// The slot the override materializes at, plus `lead` (may be negative). + Slot { + lead: i64, + }, +} + +impl RawEncoding { + /// Byte width of this encoding. + pub fn width(&self) -> usize { + match self { + RawEncoding::U8 => 1, + RawEncoding::U16 => 2, + RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, + RawEncoding::U64 | RawEncoding::I64 | RawEncoding::Slot { .. } => 8, + RawEncoding::U128 | RawEncoding::I128 => 16, + RawEncoding::Bytes32 => 32, + } + } + + /// How many times the encoded value is written, and the byte step between writes. + /// + /// Every scalar writes once. Returning this uniformly lets `materialize` place strided and scalar + /// encodings with the same loop instead of special-casing one of them. + pub fn placements(&self) -> (usize, usize) { + match self { + RawEncoding::I32Strided { count, stride } => (*count, *stride), + other => (1, other.width()), + } + } + + /// The little-endian bytes for `value`. `target_slot` is only read by [`RawEncoding::Slot`]. + pub fn encode(&self, value: &serde_json::Value, target_slot: Slot) -> Result, String> { + // Read the digits as text so nothing passes through f64, which cannot hold a u128 + // exactly. A decimal string is the only way to express values above u64::MAX in JSON. + let digits = |what: &str| -> Result { + match value { + serde_json::Value::Number(n) if n.as_u64().is_none() && n.as_i64().is_none() => { + Err(format!( + "{n} exceeds what a JSON number can hold exactly; pass this {what} as a \ + decimal string instead" + )) + } + serde_json::Value::Number(n) => Ok(n.to_string()), + serde_json::Value::String(s) => Ok(s.trim().to_string()), + other => Err(format!( + "expected a number or decimal string for {what}, found {other}" + )), + } + }; + macro_rules! int { + ($ty:ty, $what:expr) => {{ + let d = digits($what)?; + d.parse::<$ty>() + .map_err(|e| format!("invalid {}: '{d}': {e}", $what))? + .to_le_bytes() + .to_vec() + }}; + } + Ok(match self { + RawEncoding::U8 => int!(u8, "u8"), + RawEncoding::U16 => int!(u16, "u16"), + RawEncoding::U32 => int!(u32, "u32"), + RawEncoding::U64 => int!(u64, "u64"), + RawEncoding::U128 => int!(u128, "u128"), + RawEncoding::I32 | RawEncoding::I32Strided { .. } => int!(i32, "i32"), + RawEncoding::I64 => int!(i64, "i64"), + RawEncoding::I128 => int!(i128, "i128"), + RawEncoding::Bytes32 => { + let text = value + .as_str() + .ok_or_else(|| "expected a base58 pubkey string".to_string())?; + Pubkey::from_str(text) + .map_err(|e| format!("invalid pubkey '{text}': {e}"))? + .to_bytes() + .to_vec() + } + RawEncoding::Slot { lead } => { + let lead = match value { + serde_json::Value::Null => *lead, + _ => { + let d = digits("slot lead")?; + d.parse::() + .map_err(|e| format!("invalid slot lead: '{d}': {e}"))? + } + }; + let slot = if lead >= 0 { + target_slot.checked_add(lead as u64).ok_or_else(|| { + format!("slot {target_slot} plus lead {lead} exceeds u64::MAX") + })? + } else { + target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0) + }; + slot.to_le_bytes().to_vec() + } + }) + } +} + +/// Bytes that must be present for an account to be the one a raw layout describes. Without an +/// IDL there is no discriminator to resolve the type, so this is the only thing standing between +/// a raw write and silently corrupting an unrelated account. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub struct RawMagic { + pub offset: usize, + /// Expected bytes, as an ASCII string or a byte list. + pub bytes: Vec, +} + +/// A byte-level description of an account, used instead of an IDL. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +// Deliberately no `ts(export)`: override templates are not part of the TS surface, so the three +// raw-layout types have nothing referencing them there and exporting them produced no file. +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub struct RawLayout { + /// Exact account size. A mismatch means this is not the account the layout describes. + /// Serialized camelCase for the JSON API; the alias keeps the YAML snake_case like its peers. + #[serde(alias = "account_size")] + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub account_size: usize, + /// Optional type tag. Omit for programs that have none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub magic: Option, +} + +impl RawLayout { + /// Rejects an account that is not the shape this layout describes. + pub fn guard(&self, data: &[u8]) -> Result<(), String> { + if data.len() != self.account_size { + return Err(format!( + "account is {} bytes, the layout describes {}", + data.len(), + self.account_size + )); + } + if let Some(magic) = &self.magic { + let end = magic + .offset + .checked_add(magic.bytes.len()) + .ok_or_else(|| "magic offset overflow".to_string())?; + if end > data.len() || &data[magic.offset..end] != magic.bytes.as_slice() { + return Err(format!( + "magic bytes at offset {} do not match; this is not the expected account", + magic.offset + )); + } + } + Ok(()) + } + + /// Writes `values` into a copy of `data` using each property's offset and encoding. + pub fn materialize( + &self, + data: &[u8], + properties: &[Property], + values: &HashMap, + target_slot: Slot, + ) -> Result, String> { + self.guard(data)?; + let mut out = data.to_vec(); + for (name, value) in values { + let property = properties + .iter() + .find(|p| &p.path == name) + .ok_or_else(|| format!("'{name}' is not a property of this raw-layout template"))?; + let (Some(offset), Some(encoding)) = (property.offset, property.encoding.as_ref()) + else { + return Err(format!("property '{name}' has no offset or encoding")); + }; + let bytes = encoding.encode(value, target_slot)?; + let (count, stride) = encoding.placements(); + for i in 0..count { + let at = offset + .checked_add( + i.checked_mul(stride) + .ok_or_else(|| format!("stride overflow for '{name}'"))?, + ) + .ok_or_else(|| format!("offset overflow for '{name}'"))?; + let end = at + .checked_add(bytes.len()) + .ok_or_else(|| format!("offset overflow for '{name}'"))?; + if end > out.len() { + // Scalars keep the original wording; only a strided run needs to explain itself. + return Err(if count == 1 { + format!( + "'{name}' at offset {offset} + {} bytes exceeds the {} byte account", + bytes.len(), + out.len() + ) + } else { + format!( + "'{name}' writes {count} x {} bytes from offset {offset} every \ + {stride}, which exceeds the {} byte account", + bytes.len(), + out.len() + ) + }); + } + out[at..end].copy_from_slice(&bytes); + } + } + Ok(out) + } +} + /// Walks a dot-notation path: struct fields by name, array elements by index. /// /// Returns the last named field and the type at the path's end. They differ on a trailing index: @@ -1050,7 +1334,7 @@ fn idl_field_docs(idl: &Idl, account_type: &str, path: &str) -> Option { /// supply one, so field guidance is not written twice. fn describe_properties_from_idl( properties: Vec, - idl: &Idl, + idl: Option<&Idl>, account_type: &str, ) -> Vec { properties @@ -1058,7 +1342,10 @@ fn describe_properties_from_idl( .map(|yaml| { let mut property: Property = yaml.into(); if property.description.is_none() { - property.description = idl_field_docs(idl, account_type, &property.path); + // Only a fallback. A raw_layout collection with no IDL must spell out every + // description in the YAML, since there is no schema to borrow docs from. + property.description = + idl.and_then(|idl| idl_field_docs(idl, account_type, &property.path)); } property }) @@ -1067,7 +1354,7 @@ fn describe_properties_from_idl( impl YamlOverrideTemplateCollection { /// Convert collection to runtime OverrideTemplates with loaded IDL - pub fn to_override_templates(self, idl: Idl) -> Vec { + pub fn to_override_templates(self, idl: Option) -> Vec { // Convert constants once for sharing let constants: HashMap = self .constants @@ -1090,11 +1377,16 @@ impl YamlOverrideTemplateCollection { protocol: self.protocol.clone(), idl: idl.clone(), address: entry.address.into(), - properties: describe_properties_from_idl(entry.properties, &idl, &account_type), + properties: describe_properties_from_idl( + entry.properties, + idl.as_ref(), + &account_type, + ), account_type, constants: constants.clone(), tags: self.tags.clone(), llm_context: entry.llm_context, + raw_layout: self.raw_layout.clone(), } }) .collect() @@ -1132,7 +1424,7 @@ impl YamlOverrideTemplate { name: self.name, description: self.description, protocol: self.protocol, - idl: self.idl, + idl: Some(self.idl), address: self.address.into(), account_type: self.account_type, properties: self.properties.into_iter().map(Into::into).collect(), @@ -1143,6 +1435,7 @@ impl YamlOverrideTemplate { .collect(), tags: self.tags, llm_context: self.llm_context, + raw_layout: None, } } } @@ -1241,6 +1534,169 @@ mod tests { use super::PdaSeed; + /// The encoding layer must never route a value through f64: a 2^88-scaled price is a 29-digit + /// integer and f64 carries about 16 significant digits. + #[test] + fn raw_encoding_writes_large_values_exactly() { + use super::RawEncoding; + + let huge: u128 = 50u128 * (1u128 << 88); + let bytes = RawEncoding::U128 + .encode(&json!(huge.to_string()), 0) + .expect("decimal string"); + assert_eq!(u128::from_le_bytes(bytes.try_into().unwrap()), huge); + + // A bare JSON number that big has already lost digits, so it must be refused rather than + // silently written wrong. + let err = RawEncoding::U128 + .encode(&json!(1.152921504606847e21), 0) + .expect_err("an inexact JSON number must be refused"); + assert!(err.contains("decimal string"), "unexpected error: {err}"); + } + + #[test] + fn raw_encoding_handles_signed_and_slot_fields() { + use super::RawEncoding; + + let bytes = RawEncoding::I64.encode(&json!(-25599i64 << 32), 0).unwrap(); + assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); + + // The supplied value is the lead, so one property covers live and stale. + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-5), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 495); + + // The manifest lead is the default, used when no value is given. + let bytes = RawEncoding::Slot { lead: -1 } + .encode(&json!(null), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); + + // A lead that would go below zero clamps rather than wrapping. + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-10), 3) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); + + // Slot is a u64. Values above i64::MAX must not wrap through a signed cast and become zero. + let large_slot = i64::MAX as u64 + 1; + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), large_slot) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-1), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX - 1); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); + + let err = RawEncoding::Slot { lead: 0 } + .encode(&json!(1), u64::MAX) + .expect_err("a positive lead must not wrap past u64::MAX"); + assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); + } + + #[test] + fn raw_layout_rejects_writes_past_the_end_of_the_account() { + use super::{Property, RawEncoding, RawLayout}; + + let layout = RawLayout { + account_size: 16, + magic: None, + }; + let mut property = Property::field("tail".to_string()); + property.offset = Some(12); + property.encoding = Some(RawEncoding::U64); + + let err = layout + .materialize( + &[0u8; 16], + &[property], + &HashMap::from([("tail".to_string(), json!(1))]), + 0, + ) + .expect_err("a field crossing the end must be refused"); + assert!(err.contains("exceeds"), "unexpected error: {err}"); + } + + #[test] + fn i32_strided_writes_every_slot_and_nothing_between() { + use super::{Property, RawEncoding, RawLayout}; + let layout = RawLayout { + account_size: 64, + magic: None, + }; + let mut property = Property::field("ticks".to_string()); + property.offset = Some(4); + property.encoding = Some(RawEncoding::I32Strided { + count: 3, + stride: 16, + }); + + let out = layout + .materialize( + &[0u8; 64], + &[property], + &HashMap::from([("ticks".to_string(), json!(-25_600))]), + 0, + ) + .expect("strided write"); + + for i in 0..3usize { + let at = 4 + i * 16; + assert_eq!( + i32::from_le_bytes(out[at..at + 4].try_into().unwrap()), + -25_600, + "slot {i} at offset {at} should carry the value" + ); + } + // Everything outside the three four-byte spans must be untouched. + let written: Vec = (0..3).flat_map(|i| (4 + i * 16)..(8 + i * 16)).collect(); + for (i, b) in out.iter().enumerate() { + if !written.contains(&i) { + assert_eq!( + *b, 0, + "byte {i} lies between strided slots and must not change" + ); + } + } + } + + #[test] + fn i32_strided_rejects_a_run_that_leaves_the_account() { + use super::{Property, RawEncoding, RawLayout}; + let layout = RawLayout { + account_size: 32, + magic: None, + }; + let mut property = Property::field("ticks".to_string()); + property.offset = Some(4); + property.encoding = Some(RawEncoding::I32Strided { + count: 3, + stride: 16, + }); + let err = layout + .materialize( + &[0u8; 32], + &[property], + &HashMap::from([("ticks".to_string(), json!(1))]), + 0, + ) + .expect_err("a run crossing the end must be refused"); + assert!(err.contains("exceeds"), "unexpected error: {err}"); + } + #[test] fn u16_be_ref_rejects_out_of_range_values() { let seed = PdaSeed::U16BeRef("index".to_string()); From 32e310c2aa3e08897677791030547bb3c6f15af2 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 14 Sep 2026 16:05:33 +0300 Subject: [PATCH 03/20] fix(scenarios): validate raw-layout templates on registration --- crates/core/src/scenarios/registry.rs | 138 ++++++++++++++++++++++++++ crates/types/src/scenarios.rs | 68 +++++++++++++ 2 files changed, 206 insertions(+) diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 11f419e19..a421a3f91 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -204,6 +204,7 @@ impl TemplateRegistry { overrides_content: &str, protocol_name: &str, ) { + let requires_raw_layout = idl.is_none(); let collection = match serde_yaml::from_str::(overrides_content) { Ok(c) => c, @@ -213,6 +214,27 @@ impl TemplateRegistry { // Convert all templates in the collection let templates = collection.to_override_templates(idl); + // Validate the entire collection before mutating the registry, so one malformed entry + // cannot leave its valid siblings partially registered. + if requires_raw_layout { + for template in &templates { + let layout = template.raw_layout.as_ref().unwrap_or_else(|| { + panic!( + "unable to load {protocol_name} overrides: raw-layout template '{}' has no raw_layout", + template.id + ) + }); + layout + .validate_properties(&template.properties) + .unwrap_or_else(|e| { + panic!( + "unable to load {protocol_name} overrides: invalid raw-layout template '{}': {e}", + template.id + ) + }); + } + } + // Register each template for template in templates { let template_id = template.id.clone(); @@ -614,6 +636,122 @@ templates: assert_eq!(u64::from_le_bytes(output[8..16].try_into().unwrap()), 42); } + #[test] + fn raw_layout_collection_rejects_invalid_write_definitions_at_load_time() { + fn rejected(yaml: &str, expected: &str) { + let result = std::panic::catch_unwind(|| { + let mut registry = TemplateRegistry::default(); + registry.load_raw_layout_overrides(yaml, "broken"); + }); + let panic = result.expect_err("invalid raw-layout collection must be rejected"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic message"); + assert!( + message.contains(expected), + "expected {expected:?} in {message:?}" + ); + } + + rejected( + r#" +protocol: Broken +version: v1 +templates: + - id: no-layout + name: No layout + description: Invalid + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: [] +"#, + "has no raw_layout", + ); + + rejected( + r#" +protocol: Broken +version: v1 +raw_layout: { account_size: 16 } +templates: + - id: no-offset + name: No offset + description: Invalid + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: + - { path: value, encoding: u64 } +"#, + "missing an offset", + ); + + rejected( + r#" +protocol: Broken +version: v1 +raw_layout: { account_size: 16 } +templates: + - id: no-encoding + name: No encoding + description: Invalid + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: + - { path: value, offset: 8 } +"#, + "missing an encoding", + ); + + rejected( + r#" +protocol: Broken +version: v1 +raw_layout: { account_size: 16 } +templates: + - id: out-of-bounds + name: Out of bounds + description: Invalid + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: + - { path: value, offset: 12, encoding: u64 } +"#, + "beyond the 16 byte account", + ); + + let mut registry = TemplateRegistry::default(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + registry.load_raw_layout_overrides( + r#" +protocol: Broken +version: v1 +raw_layout: { account_size: 16 } +templates: + - id: valid-sibling + name: Valid sibling + description: Valid alone + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: + - { path: value, offset: 8, encoding: u64 } + - id: invalid-sibling + name: Invalid sibling + description: Invalid + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: + - { path: value, encoding: u64 } +"#, + "broken", + ); + })); + assert!( + result.is_err(), + "invalid sibling must reject the collection" + ); + assert_eq!( + registry.count(), + 0, + "validation must finish before any sibling is registered" + ); + } + #[test] fn test_jupiter_template_loads_correctly() { let registry = TemplateRegistry::new(); diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 0e25a7a4a..fab64d27b 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1159,6 +1159,74 @@ pub struct RawLayout { } impl RawLayout { + /// Validates every byte range a template can write before the template enters the registry. + pub fn validate_properties(&self, properties: &[Property]) -> Result<(), String> { + if self.account_size == 0 { + return Err("raw_layout account_size must be greater than zero".to_string()); + } + + if let Some(magic) = &self.magic { + let end = magic + .offset + .checked_add(magic.bytes.len()) + .ok_or_else(|| "raw_layout magic offset overflow".to_string())?; + if end > self.account_size { + return Err(format!( + "raw_layout magic at offset {} + {} bytes exceeds the {} byte account", + magic.offset, + magic.bytes.len(), + self.account_size + )); + } + } + + for property in properties { + // Constant references select PDA seeds or catalog values; they are not account writes. + if property.is_constant_ref() { + continue; + } + + let offset = property.offset.ok_or_else(|| { + format!( + "writable raw-layout property '{}' is missing an offset", + property.path + ) + })?; + let encoding = property.encoding.as_ref().ok_or_else(|| { + format!( + "writable raw-layout property '{}' is missing an encoding", + property.path + ) + })?; + let (count, stride) = encoding.placements(); + if count == 0 { + return Err(format!( + "writable raw-layout property '{}' has zero placements", + property.path + )); + } + + let final_offset = offset + .checked_add( + (count - 1) + .checked_mul(stride) + .ok_or_else(|| format!("stride overflow for '{}'", property.path))?, + ) + .ok_or_else(|| format!("offset overflow for '{}'", property.path))?; + let end = final_offset + .checked_add(encoding.width()) + .ok_or_else(|| format!("offset overflow for '{}'", property.path))?; + if end > self.account_size { + return Err(format!( + "writable raw-layout property '{}' ends at byte {}, beyond the {} byte account", + property.path, end, self.account_size + )); + } + } + + Ok(()) + } + /// Rejects an account that is not the shape this layout describes. pub fn guard(&self, data: &[u8]) -> Result<(), String> { if data.len() != self.account_size { From b019348dd8b3c94483bb20630a5bd8128875713b Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:08:01 +0300 Subject: [PATCH 04/20] feat(scenarios): add GoonFi state preparation GoonFi is a two-account dark AMM with no IDL: a 32-byte external price oracle plus a 2048-byte market carrying the reference band that guards it. - Four raw-layout templates over the two accounts (price, reference band, freshness, stale quote), plus a live market catalog read from program accounts. - Two builders. Price moves the oracle bid and ask together with the market's reference band and a persistent freshness stamp, because the deployed program rejects a decoupled move with 0x24. Liquidity resolves both token vaults from the market's own pointers and scales their balances. - Three MCP tools on the generic scenario path: list_goonfi_markets, create_goonfi_price_scenario and create_goonfi_liquidity_scenario. No protocol HTTP endpoints. - The Slot raw encoding now takes a width, so the 4-byte freshness field no longer clobbers the decay multiplier stored beside it. - Live gated tests against the deployed program. The shared live-test helper duplicates the Tessera branch's on purpose; they fold together when the branches converge. --- Cargo.lock | 3 + crates/core/Cargo.toml | 2 + .../src/scenarios/protocols/goonfi/README.md | 139 +++ .../src/scenarios/protocols/goonfi/mod.rs | 1 + .../protocols/goonfi/v1/liquidity.rs | 417 +++++++ .../protocols/goonfi/v1/market_overrides.yaml | 55 + .../scenarios/protocols/goonfi/v1/markets.rs | 284 +++++ .../src/scenarios/protocols/goonfi/v1/mod.rs | 15 + .../protocols/goonfi/v1/oracle_overrides.yaml | 130 ++ .../scenarios/protocols/goonfi/v1/price.rs | 447 +++++++ crates/core/src/scenarios/protocols/mod.rs | 1 + crates/core/src/scenarios/registry.rs | 25 +- crates/core/src/surfnet/svm.rs | 11 +- crates/core/src/tests/goonfi/mod.rs | 1110 +++++++++++++++++ crates/core/src/tests/live.rs | 68 + crates/core/src/tests/mod.rs | 4 + crates/mcp/Cargo.toml | 2 + crates/mcp/src/surfpool/mod.rs | 256 +++- crates/types/src/scenarios.rs | 134 +- 19 files changed, 3084 insertions(+), 20 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/goonfi/README.md create mode 100644 crates/core/src/scenarios/protocols/goonfi/mod.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/markets.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/mod.rs create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/goonfi/v1/price.rs create mode 100644 crates/core/src/tests/goonfi/mod.rs create mode 100644 crates/core/src/tests/live.rs diff --git a/Cargo.lock b/Cargo.lock index 141bce739..eda85fc42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12154,6 +12154,7 @@ dependencies = [ "solana-packet", "solana-program-option 3.1.0", "solana-program-pack 3.1.0", + "solana-program-runtime", "solana-pubkey 3.0.0", "solana-pubsub-client", "solana-rpc-client", @@ -12218,6 +12219,8 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "solana-account 4.3.1", + "solana-commitment-config", "solana-keypair", "solana-pubkey 3.0.0", "solana-signer", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 53f510da0..36631ffb7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -115,6 +115,8 @@ axum = { version = "0.8", default-features = false, features = ["tokio", "http1" [dev-dependencies] ed25519-dalek = "1.0.1" +# Only the GoonFi live suite uses it, to host the CPI wrapper that drives the deployed program. +solana-program-runtime = "4.1.2" libsecp256k1 = "0.7.2" p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } test-case = { workspace = true } diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md new file mode 100644 index 000000000..74a7a134b --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -0,0 +1,139 @@ +# GoonFi + +GoonFi V2 uses raw account layouts rather than an IDL. Each market points to a 32-byte +oracle owned by a companion publisher program. The oracle stores bid/ask prices; the +market stores the reference prices that guard them. Surfpool prepares these accounts +before a user runs a strategy. Product scenarios do not construct or submit swaps. + +## Pinned deployment + +The live tests in `crates/core/src/tests/goonfi/mod.rs` check these ProgramData sizes, +deployment slots and ELF hashes before replaying the program: + +| | Trading program | Oracle publisher | +|---|---|---| +| Program | `goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE` | `dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu` | +| ProgramData | `124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s` | `7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z` | +| ProgramData bytes | 252,429 | 557 | +| Deployment slot | 438563879 | 404369628 | +| ELF SHA-256 | `73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e` | `0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695` | + +A deployment change requires revalidation. These are test pins, not an upgrade-monitoring +service or a claim that every future deployment has the same layout. + +## Layouts and templates + +A market is 2048 bytes with magic `30 bc 2f 35 34 58 32 9a` at offset 0. Its base/quote +mints are at offsets 80/112, vaults at 144/176, and oracle pointer at 208. The oracle is +32 bytes with no discriminator. Both YAML layouts declare their expected program owner; +the shared materializer checks ownership before writing, then validates size, optional +magic bytes and write bounds. A failed owner check skips the override with a warning. + +| Template | Account | Fields | +|---|---|---| +| `goonfi-price` | Oracle | Bid and ask, u64 at offsets 0 and 8 | +| `goonfi-stale-quote` | Oracle | Freshness slot, u32 at offset 16; default lead -2000 | +| `goonfi-freshness` | Oracle | Freshness slot, u32 at offset 16; default lead 0 | +| `goonfi-reference-band` | Market | Reference prices, u64 at offsets 1712 and 1720 | + +Prices use the human pair price multiplied by `10^6`, independent of mint decimals. +For example, 99.74 quote tokens per base token becomes the integer string `"99740000"`. +Use strings for u64 price values to preserve precision in JSON and Studio. + +Slot templates write exactly four bytes. The u32 multiplier at offset 20 and the +millisecond timestamp at offset 24 remain untouched. A slot value of `null` selects the +template's default lead; an integer specifies a lead relative to the materialization +slot. The resulting slot must fit u32. + +## Catalog and price scenario + +The backend exposes three GoonFi MCP tools: + +- `list_goonfi_markets` discovers program accounts and validates market, oracle and mint + relationships. It returns market/oracle addresses, labels, mint addresses and decimals. + The YAML files contain no market catalog, and discovery does not require a fixed count. +- `create_goonfi_price_scenario` accepts a market address and a positive human price with + up to six decimal places. It resolves the oracle from the market account, validates + both accounts, and composes three overrides: equal oracle bid/ask, equal market reference + prices, and persistent freshness. An omitted market selects the default SOL/USDC market. +- `create_goonfi_liquidity_scenario` accepts a market address and per-vault remaining basis + points. It resolves both token vaults from the market's own pointers (offsets 144 and + 176), reads each current balance, validates the vault and oracle owners, and scales each + vault through `spl-token-account-balance`: 0 drains a vault so a swap rejects with `0x1`, + 10000 leaves it unchanged. A persistent freshness override keeps the rejection about + liquidity rather than a stale quote. Both default to 0; an omitted market selects the + default SOL/USDC market. + +These tools accept optional `surfnet_port`, defaulting to 8899, and read through the local +Surfnet RPC. Missing accounts fall back to that Surfnet's datasource. The price tool +stages through the shared Studio scenario API; Play registers the scenario. + +Studio's PMM fair-value dialog selects a protocol, a live market and a human price. It +calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnet_port`, +matching the Tessera dialog convention. Consequently, these Studio GoonFi calls use the +backend's default RPC port. Studio retains only each catalog entry's market address and +label; the backend resolves the oracle when creating a price scenario. + +The price builder does not set `fetchBeforeUse`: the accounts read at creation retain +local edits, and only the specified fields are changed. Freshness uses `persist: true` +to stamp each subsequent materialization slot. These settings do not establish +transactional atomicity across all overrides in a scenario. + +## Composing other prepared states + +The four templates remain available through the generic scenario editor and AI flow. +There are no dedicated GoonFi spread or delayed-event builders. + +For a stale quote, target the oracle returned by `list_goonfi_markets` with +`goonfi-stale-quote`. Do not run a persistent freshness override over the same interval: +it would erase the stale state. Recovery can use `goonfi-freshness` at a later relative +slot. The Studio AI chip requests a stale-quote scenario through this generic flow. + +For depletion, `create_goonfi_liquidity_scenario` resolves the vaults from the market and +scales each balance for you; the AI chip calls it directly. Composing the same by hand +means reading the selected vault address from market offset 144 or 176, checking its token +program, and using `spl-token-account-balance` with an absolute amount, applied once. The +override does not recalculate percentages at execution time. + +## Behavioral verification + +The live suite fetches deployed account data and runs the pinned trading ELF in LiteSVM, +using a builtin wrapper for the Jupiter-shaped CPI. It checks: + +- Unchanged encoding produces the same fill; coupled price/reference changes alter output. +- Raising only the bid or lowering only the ask rejects with `0x24` (reference-band guard). +- Quotes decay with slot age and eventually reject with `0x15`. Changing the multiplier + changes decay in the tested fixture; stamping the slot restores freshness. Changing + the wall-clock timestamp alone does not change the tested fill. +- An impossible minimum output rejects with `0xf`. +- A successful sell still fills with exactly enough quote inventory. One atomic unit less + or an empty quote vault rejects with `0x1`, with the trade input held constant. +- The price builder's three overrides register and materialize through the production + path on two markets, preserving unrelated bytes and refreshing the u32 slot afterwards. +- Live discovery returns valid market/oracle relationships without a fixed catalog count. + +Behavior fixtures fund local vaults to at least 10,000 whole tokens and retain wrapped SOL +backing. This isolates price, ageing and inventory changes from fluctuating live liquidity; +it does not prove that the same trade currently has sufficient mainnet liquidity. Layout +and discovery checks use unfunded fetched accounts. Owner-predicate unit tests live in +`crates/types/src/scenarios.rs`. This suite does not provide a `pmm-sim` differential run +or a Studio browser test. + +Run all GoonFi unit and live checks serially: + +```bash +SURFPOOL_TEST_RPC_URL= cargo test -p surfpool-core --features integration-tests \ + goonfi -- --test-threads=1 --nocapture +``` + +The RPC variable is optional and defaults to the public mainnet endpoint. A private endpoint +can avoid public RPC rate limits. Re-run after a program upgrade or account-layout change. + +## Known boundaries + +The staleness window's on-chain source and exact decay formula remain unidentified. +Observed windows vary by market and time; historical slot ages are not fixed protocol +limits. The global account and other market fields are forked without assigned override +semantics. No enable/disable field is exposed. Direct top-level swaps are not covered by +the CPI replay, and the exact tolerance of the reference-band guard is not established +by these tests. diff --git a/crates/core/src/scenarios/protocols/goonfi/mod.rs b/crates/core/src/scenarios/protocols/goonfi/mod.rs new file mode 100644 index 000000000..a3a6d96c3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs new file mode 100644 index 000000000..adf3f58bd --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -0,0 +1,417 @@ +//! GoonFi liquidity state preparation. +//! +//! A market draws liquidity from two SPL token vaults whose addresses live in the market account +//! at fixed offsets. Unlike price or depth, the balances are not in the protocol account itself but +//! in those separate token accounts, so this scales each vault through the generic +//! `spl-token-account-balance` template. Draining a vault to zero makes the deployed program reject +//! a swap with custom error 0x1; a fresh re-stamp keeps that rejection about liquidity and not a +//! stale quote. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +use super::{ + GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, +}; + +/// Read, never written, so no template declares them. +const BASE_MINT_OFFSET: usize = 80; +const QUOTE_MINT_OFFSET: usize = 112; +const BASE_VAULT_OFFSET: usize = 144; +const QUOTE_VAULT_OFFSET: usize = 176; +/// The SPL token account amount field. +const AMOUNT_OFFSET: usize = 64; + +const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Both overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// 10000 basis points leaves a vault untouched; 0 drains it. +const FULL_BPS: u16 = 10_000; + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiLiquidityPreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub base_vault: Pubkey, + pub quote_vault: Pubkey, + pub base_amount: u64, + pub quote_amount: u64, +} + +/// The two SPL token vaults a market draws liquidity from, read from the market's own pointers. +/// +/// Validates the market first: the shared raw-layout guard has no owner predicate, so the owner +/// check in `validate_goonfi_market_layout` is what keeps these offsets pointed at a real market. +pub fn vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> { + validate_goonfi_market_layout(market_account)?; + let base = read_pubkey(&market_account.data, BASE_VAULT_OFFSET)?; + let quote = read_pubkey(&market_account.data, QUOTE_VAULT_OFFSET)?; + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(invalid("market carries invalid vault pointers")); + } + Ok([base, quote]) +} + +/// Scales each vault balance to the requested basis points and keeps the quote fresh. +/// +/// `market_account` is the source of truth for the vault and oracle addresses; the three passed +/// accounts are the base vault, quote vault and oracle the caller fetched by those addresses, in +/// that order. A side left at 10000 bps is untouched and gets no override. +pub fn build_goonfi_liquidity_scenario( + market: Pubkey, + market_account: &Account, + base_vault_account: &Account, + quote_vault_account: &Account, + oracle_account: &Account, + base_remaining_bps: u16, + quote_remaining_bps: u16, +) -> SurfpoolResult { + if [base_remaining_bps, quote_remaining_bps] + .iter() + .any(|bps| *bps > FULL_BPS) + { + return Err(invalid( + "remaining liquidity must be 0..=10000 basis points; 0 drains a vault, 10000 leaves it unchanged", + )); + } + if base_remaining_bps == FULL_BPS && quote_remaining_bps == FULL_BPS { + return Err(invalid( + "both vaults left unchanged; set a lower basis point value to drain at least one side", + )); + } + + let [base_vault, quote_vault] = vault_addresses(market_account)?; + let oracle = GoonfiMarket::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + + let base_amount = vault_amount(base_vault_account)?; + let quote_amount = vault_amount(quote_vault_account)?; + + let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; + let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let label = market_label(&base_mint, "e_mint); + + let registry = TemplateRegistry::new(); + let liquidity = template(®istry, LIQUIDITY_TEMPLATE)?; + + let mut scenario = Scenario::new( + format!("GoonFi {label} liquidity drain"), + format!( + "Prepare GoonFi {label} market ({market}) vaults to {} of base and {} of quote liquidity; no swap is sent.", + remaining_label(base_remaining_bps), + remaining_label(quote_remaining_bps) + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "liquidity-drain".to_string(), + ]; + + for (side, vault, current, bps) in [ + ("base", base_vault, base_amount, base_remaining_bps), + ("quote", quote_vault, quote_amount, quote_remaining_bps), + ] { + if bps == FULL_BPS { + continue; + } + let scaled = (u128::from(current) * u128::from(bps) / u128::from(FULL_BPS)) as u64; + scenario.add_override( + OverrideInstance::new( + liquidity.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(vault.to_string()), + ) + .with_values(HashMap::from([( + "amount".to_string(), + serde_json::json!(scaled.to_string()), + )])) + .with_label(format!("Drain GoonFi {side} vault")), + ); + } + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null keeps the + // template's own lead of zero. Persisted so the quote stays inside the staleness window and the + // swap the drained state is proven against is rejected for liquidity (0x1), not a stale quote. + scenario.add_override( + OverrideInstance::new( + FRESHNESS_TEMPLATE.to_string(), + PREPARATION_SLOT, + AccountAddress::Pubkey(oracle.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true), + ); + + Ok(GoonfiLiquidityPreparation { + scenario, + market, + base_vault, + quote_vault, + base_amount, + quote_amount, + }) +} + +/// The SPL token vaults are 32 undiscriminated-looking bytes at the front; the owner check is the +/// real discriminator that keeps a balance write out of a foreign account. +fn vault_amount(account: &Account) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(invalid("vault is not owned by a supported token program")); + } + let bytes: [u8; 8] = account + .data + .get(AMOUNT_OFFSET..AMOUNT_OFFSET + 8) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid("vault is too small to be an SPL token account"))?; + Ok(u64::from_le_bytes(bytes)) +} + +fn remaining_label(bps: u16) -> String { + format!("{}.{:02}%", bps / 100, bps % 100) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data + .get(offset..offset + 32) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid("market vault bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenarios::protocols::goonfi::v1::{GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID}; + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + const USDC: Pubkey = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + + fn market_account(base_vault: &Pubkey, quote_vault: &Pubkey) -> Account { + let mut data = vec![0u8; 2048]; + // Magic tag every live market shares. + data[0..8].copy_from_slice(&[48, 188, 47, 53, 52, 88, 50, 154]); + data[BASE_MINT_OFFSET..BASE_MINT_OFFSET + 32].copy_from_slice(WSOL.as_ref()); + data[QUOTE_MINT_OFFSET..QUOTE_MINT_OFFSET + 32].copy_from_slice(USDC.as_ref()); + data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].copy_from_slice(base_vault.as_ref()); + data[QUOTE_VAULT_OFFSET..QUOTE_VAULT_OFFSET + 32].copy_from_slice(quote_vault.as_ref()); + data[208..240].copy_from_slice(FIXTURE_ORACLE.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn vault(amount: u64) -> Account { + let mut data = vec![0u8; 165]; + data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); + Account { + data, + owner: spl_token_interface::ID, + ..Account::default() + } + } + + fn oracle() -> Account { + Account { + data: vec![0u8; 32], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + #[test] + fn drains_both_vaults_and_keeps_the_quote_fresh() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let market = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + market, + &market_account(&base_vault, "e_vault), + &vault(2_441_078_070_812), + &vault(216_136_231_615), + &oracle(), + 0, + 0, + ) + .unwrap(); + + assert_eq!(preparation.base_vault, base_vault); + assert_eq!(preparation.quote_vault, quote_vault); + // A friendly pair label, not the raw market pubkey. + assert_eq!( + preparation.scenario.name, + "GoonFi SOL/USDC liquidity drain" + ); + let [base, quote, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected base drain, quote drain and freshness overrides"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + assert_eq!(quote.account, AccountAddress::Pubkey(quote_vault.to_string())); + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); + assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); + assert!(!base.fetch_before_use); + assert!(!base.persist); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(FIXTURE_ORACLE.to_string()) + ); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_partially_and_skips_an_unchanged_side() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market_account(&base_vault, "e_vault), + &vault(1_000), + &vault(999), + &oracle(), + 2_500, + FULL_BPS, + ) + .unwrap(); + + let [base, freshness] = &preparation.scenario.overrides[..] else { + panic!("the unchanged quote side must not get an override"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + // 1000 * 2500 / 10000, exact integer arithmetic. + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("250"))); + assert_eq!(freshness.values.len(), 1); + } + + #[test] + fn rejects_bad_basis_points_and_accounts() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let good_market = market_account(&base_vault, "e_vault); + + // Out of range and a no-op leave nothing to prepare. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &oracle(), + 10_001, + 0 + ) + .is_err() + ); + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &oracle(), + FULL_BPS, + FULL_BPS + ) + .is_err() + ); + + // A foreign account of the same size passes the raw guard, so the owner check must reject. + let foreign_market = Account { + owner: Pubkey::new_unique(), + ..market_account(&base_vault, "e_vault) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &foreign_market, + &vault(1), + &vault(1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A vault not owned by a token program is not a real vault. + let foreign_vault = Account { + owner: Pubkey::new_unique(), + ..vault(1) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &foreign_vault, + &vault(1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A foreign oracle carries no magic, so its owner is the only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(1), + &vault(1), + &foreign_oracle, + 0, + 0 + ) + .is_err() + ); + } + + #[test] + fn resolves_vault_addresses_from_the_market() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let [base, quote] = vault_addresses(&market_account(&base_vault, "e_vault)).unwrap(); + assert_eq!(base, base_vault); + assert_eq!(quote, quote_vault); + + let mut zero_pointer = market_account(&base_vault, "e_vault); + zero_pointer.data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].fill(0); + assert!(vault_addresses(&zero_pointer).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml new file mode 100644 index 000000000..6a218ccfa --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml @@ -0,0 +1,55 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: MarketState + +# The write target here is the market account itself: 2048 bytes owned by the GoonFi program, +# tagged by the 8 magic bytes every live market shares. Mints, vaults and the oracle pointer live +# in cleartext at fixed offsets; the only fields a product flow writes are the two reference +# prices the deployed program uses as an anti-manipulation band around the oracle. +raw_layout: + account_size: 2048 + owner: goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE + magic: + offset: 0 + bytes: [48, 188, 47, 53, 52, 88, 50, 154] + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-reference-band + name: Override GoonFi Reference Band + description: Move the market's reference prices that band-guard the oracle + idl_account_name: MarketState + address: + type: pubkey + value: GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC + properties: + - path: reference_price_a_x1e6 + offset: 1712 + encoding: u64 + label: Reference price A + description: "First reference anchor, human pair price times 10^6. Same scale as the oracle's bid and ask." + - path: reference_price_b_x1e6 + offset: 1720 + encoding: u64 + label: Reference price B + description: "Second reference anchor, human pair price times 10^6. The pair's order is not fixed; scale both by the same factor." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, scaled by the same factor as the oracle price move they + accompany. The deployed program rejects a swap with custom error 0x24 when the oracle + price it is about to use falls outside the band these two anchors define, in the + direction unfavorable to the venue: a raised bid blocks sells, a lowered ask blocks buys. + + This template exists as the second half of goonfi-price: apply both to shift a market's + price beyond a fraction of a percent. Use the market address returned by + list_goonfi_markets; its oracle field identifies the paired price account. The + GoonFi price builder composes the pair (plus freshness) automatically; composing by hand + and skipping either account breaks the invariant with error 0x24. + + Set fetchBeforeUse: true so the live market is forked before your reference prices apply; on + a fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs new file mode 100644 index 000000000..e4f4a6ba3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -0,0 +1,284 @@ +use std::collections::HashMap; + +use solana_account::Account; +use solana_account_decoder::UiAccountEncoding; +use solana_client::{ + rpc_config::RpcAccountInfoConfig, + rpc_filter::{Memcmp, RpcFilterType}, +}; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::VERIFIED_TOKENS_BY_SYMBOL; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, + surfnet::remote::SurfnetRemoteClient, + types::MintAccount, +}; + +use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket}; + +#[derive(Debug, PartialEq)] +pub struct GoonfiDiscoveredMarket { + pub address: Pubkey, + pub oracle: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub base_decimals: u8, + pub quote_decimals: u8, +} + +impl GoonfiDiscoveredMarket { + pub fn label(&self) -> String { + market_label(&self.base_mint, &self.quote_mint) + } +} + +/// A human pair label from the two mints, e.g. "SOL/USDC". Falls back to a mint's full address +/// when it is not in the verified token list, so an unknown pair is still uniquely named. +pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { + let symbol = |mint: &Pubkey| { + let address = mint.to_string(); + VERIFIED_TOKENS_BY_SYMBOL + .values() + .filter(|token| token.address == address) + .map(|token| token.symbol.as_str()) + .min() + .map(str::to_string) + .unwrap_or(address) + }; + format!("{}/{}", symbol(base_mint), symbol(quote_mint)) +} + +fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 3]> { + let oracle = GoonfiMarket::oracle_address(account)?; + let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); + let quote = Pubkey::new_from_array(account.data[112..144].try_into().unwrap()); + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(SurfpoolError::internal( + "GoonFi market has invalid mint identities", + )); + } + Ok([base, quote, oracle]) +} + +fn mint_decimals(account: &Account) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(SurfpoolError::internal( + "GoonFi mint is not owned by a supported token program", + )); + } + Ok(MintAccount::unpack(&account.data)?.decimals()) +} + +fn resolve_market( + address: Pubkey, + account: &Account, + references: &HashMap, +) -> SurfpoolResult { + let [base, quote, oracle] = market_references(account)?; + let required = |address: &Pubkey| { + references.get(address).ok_or_else(|| { + SurfpoolError::internal(format!("GoonFi referenced account {address} was not found")) + }) + }; + GoonfiMarket::validate(address, account, required(&oracle)?)?; + Ok(GoonfiDiscoveredMarket { + address, + oracle, + base_mint: base, + quote_mint: quote, + base_decimals: mint_decimals(required(&base)?)?, + quote_decimals: mint_decimals(required("e)?)?, + }) +} + +pub async fn discover_goonfi_markets( + client: &SurfnetRemoteClient, +) -> SurfpoolResult> { + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .and_then(|template| template.raw_layout.as_ref()) + .ok_or_else(|| SurfpoolError::internal("GoonFi market layout is unavailable"))?; + let mut filters = vec![RpcFilterType::DataSize(layout.account_size as u64)]; + if let Some(magic) = &layout.magic { + filters.push(RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + magic.offset, + magic.bytes.clone(), + ))); + } + let accounts = client + .get_program_accounts( + &GOONFI_PROGRAM_ID, + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + commitment: Some(CommitmentConfig::confirmed()), + ..Default::default() + }, + Some(filters), + ) + .await? + .into_result()?; + let accounts = accounts + .into_iter() + .map(|(address, encoded)| { + let account: Account = encoded.to_account().ok_or_else(|| { + SurfpoolError::internal(format!("Could not decode GoonFi market {address}")) + })?; + market_references(&account)?; + Ok((address, account)) + }) + .collect::>>()?; + let mut addresses = Vec::new(); + for (_, account) in &accounts { + addresses.extend(market_references(account)?); + } + addresses.sort_unstable(); + addresses.dedup(); + let mut references = HashMap::new(); + for batch in addresses.chunks(100) { + let fetched = client + .get_multiple_accounts(batch, CommitmentConfig::confirmed()) + .await?; + for (address, account) in batch.iter().zip(fetched) { + references.insert(*address, account.map_account()?); + } + } + let mut markets = accounts + .iter() + .map(|(address, account)| resolve_market(*address, account, &references)) + .collect::>>()?; + markets.sort_by_cached_key(|market| { + ( + market.address != GOONFI_DEFAULT_MARKET, + market.label(), + market.address, + ) + }); + Ok(markets) +} + +#[cfg(test)] +mod tests { + use solana_program_pack::Pack; + + use super::*; + use crate::scenarios::protocols::goonfi::v1::GOONFI_ORACLE_PROGRAM_ID; + + fn fixture() -> (Pubkey, Account, HashMap) { + let address = Pubkey::new_unique(); + let base = Pubkey::new_unique(); + let quote = Pubkey::new_unique(); + let oracle = Pubkey::new_unique(); + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .unwrap() + .raw_layout + .as_ref() + .unwrap(); + let mut market = Account { + owner: GOONFI_PROGRAM_ID, + data: vec![0; layout.account_size], + ..Account::default() + }; + let magic = layout.magic.as_ref().unwrap(); + market.data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + market.data[80..112].copy_from_slice(base.as_ref()); + market.data[112..144].copy_from_slice(quote.as_ref()); + market.data[208..240].copy_from_slice(oracle.as_ref()); + let mint = |decimals| { + let mut account = Account { + owner: spl_token_interface::ID, + data: vec![0; spl_token_interface::state::Mint::LEN], + ..Account::default() + }; + spl_token_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut account.data); + account + }; + ( + address, + market, + HashMap::from([ + (base, mint(9)), + (quote, mint(6)), + ( + oracle, + Account { + owner: GOONFI_ORACLE_PROGRAM_ID, + data: vec![0; 32], + ..Account::default() + }, + ), + ]), + ) + } + + #[test] + fn goonfi_discovery_accepts_uncataloged_markets_and_preserves_mint_identity() { + let (address, account, references) = fixture(); + let result = resolve_market(address, &account, &references).unwrap(); + assert_eq!(result.address, address); + assert_eq!((result.base_decimals, result.quote_decimals), (9, 6)); + assert_eq!( + result.label(), + format!("{}/{}", result.base_mint, result.quote_mint) + ); + assert_eq!(result.oracle, market_references(&account).unwrap()[2]); + } + + #[test] + fn goonfi_discovery_rejects_invalid_market_layouts_and_mint_identities() { + let (_, account, _) = fixture(); + for invalid in 0..5 { + let mut account = account.clone(); + match invalid { + 0 => account.owner = Pubkey::new_unique(), + 1 => { + account.data.pop(); + } + 2 => account.data[0] ^= 1, + 3 => account.data[80..112].fill(0), + _ => { + let base = account.data[80..112].to_vec(); + account.data[112..144].copy_from_slice(&base); + } + } + assert!( + market_references(&account).is_err(), + "invalid case {invalid}" + ); + } + } + + #[test] + fn goonfi_discovery_rejects_missing_or_invalid_referenced_accounts() { + let (address, account, references) = fixture(); + let [base, _, oracle] = market_references(&account).unwrap(); + for invalid in 0..5 { + let mut references = references.clone(); + match invalid { + 0 => { + references.remove(&oracle); + } + 1 => references.get_mut(&oracle).unwrap().owner = Pubkey::new_unique(), + 2 => { + references.get_mut(&oracle).unwrap().data.pop(); + } + 3 => references.get_mut(&base).unwrap().owner = Pubkey::new_unique(), + _ => references.get_mut(&base).unwrap().data.fill(0), + } + assert!( + resolve_market(address, &account, &references).is_err(), + "invalid case {invalid}" + ); + } + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs new file mode 100644 index 000000000..c788b126d --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -0,0 +1,15 @@ +mod liquidity; +mod markets; +mod price; + +pub use liquidity::{ + GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses, +}; + +pub use price::{ + GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, + GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, + validate_goonfi_oracle_layout, +}; + +pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml new file mode 100644 index 000000000..70ca05004 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml @@ -0,0 +1,130 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: PriceOracle + +# The write target of every template here is the market's price-oracle account: 32 bytes owned by +# the companion publisher program. It carries no discriminator, so the byte guard can only pin +# the size; the owner predicate below is what keeps a raw write out of a foreign 32-byte account, +# and the builder additionally resolves the oracle through the market account's own pointer. +raw_layout: + account_size: 32 + owner: dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-price + name: Override GoonFi Price + description: Move a GoonFi market's oracle bid and ask atomically in both directions + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: bid_price_x1e6 + offset: 0 + encoding: u64 + label: Bid price + description: "The human pair price the venue buys base at, multiplied by 10^6. Independent of mint decimals." + - path: ask_price_x1e6 + offset: 8 + encoding: u64 + label: Ask price + description: "The human pair price the venue sells base at, multiplied by 10^6. Live oracles publish it at or above the bid; keep that shape." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, with ask >= bid. Values are the human pair price times + 10^6 regardless of mint decimals: SOL at 99.74 USDC is bid_price_x1e6 "99740000". Use + decimal integer strings, not JSON numbers. + + THE PRICE IS BAND-GUARDED ACROSS TWO ACCOUNTS. The deployed program rejects a swap with + custom error 0x24 when the oracle price sits outside the reference band stored in the + market account - a decoupled move of even 5% is proven to reject, and the live oracle + tracks its band within a fraction of a percent. Always apply goonfi-reference-band to the + market address returned by list_goonfi_markets, scaled by the same factor. The GoonFi price + builder composes both overrides plus freshness automatically; composing the raw templates + by hand and skipping one of them breaks the invariant. + + Set fetchBeforeUse: true so the live oracle is forked before your bid and ask apply; on a + fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. + + GoonFi rejects a quote whose oracle is past its staleness window with custom error 0x15. + Pair long-running scenarios with goonfi-freshness. + + - id: goonfi-stale-quote + name: Make GoonFi Quote Stale + description: Age a GoonFi oracle past its rejection window + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: -2000 + width: 4 + label: Slot lead + description: >- + How far behind the materialization slot to place the oracle's update slot, as a + negative integer. Pass null to use -2000, past every observed window including the + stablecoin tier's. + llm_context: | + The value you pass IS the lead: Surfpool writes the materialization slot plus it, clamped + at zero. Pass null to take the -2000 default. The slot field is 4 bytes; the dynamic + multiplier stored beside it stays untouched. + + The rejection window is per-market and publisher-adjustable: ages 16 and 21 were observed + on volatile pairs and windows of a few hundred slots on stablecoin pairs, all on one day. + These observations are not fixed limits. Inside the window the deployed program decays + the quote with age - + faster the higher the oracle's multiplier at offset 20 - before rejecting outright with + custom error 0x15, so a small negative lead prepares a degraded-but-fillable quote and + the -2000 default prepares a rejected one on every observed market. + + Do not persist this override: the quote should stay stale. For a standalone stale quote on a + fresh fork, set fetchBeforeUse: true so the live oracle is forked before the ageing applies; + an override on an account not yet local is skipped. In a lifecycle where an earlier + goonfi-freshness override already forked and edited the oracle locally, use false so this + override does not refetch remote bytes over that local edit: refresh once at slot zero, age + at the requested relative slot, then optionally refresh with persist at a later recovery + slot. The initial refresh must not persist or it will erase the stale event. Before a + delayed event, the initial quote naturally ages. Keep override labels short ("SOL/USDC stale + quote"). + + - id: goonfi-freshness + name: Refresh GoonFi Quote + description: Publish the materialization slot into the oracle's freshness field + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: 0 + width: 4 + label: Current materialization slot + description: Slot lead, as an integer. Pass null to take the lead of zero and write the materialization slot itself. + llm_context: | + GoonFi rejects a swap whose oracle has aged past its per-market window with custom error + 0x15 - a loud failure, unlike BisonFi's silent zero - and decays the quote with age before + that, at a rate proportional to the oracle's multiplier at offset 20. Re-stamping this + field alone restores the full quote; the market account's own slot fields do not gate + freshness. + + Pass null for last_update_slot to take this template's lead of zero, which writes the + exact materialization slot. A number would be read as the lead instead. Use persist: true + when the prepared state must remain executable beyond the window; each application then + writes its own slot. + + Set fetchBeforeUse: true when this is the first override to touch the oracle on a fresh fork, + so the live account is forked before the stamp; an override on an account not yet local is + skipped. Use false when an earlier override in the same scenario already forked it. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs new file mode 100644 index 000000000..61cda2863 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -0,0 +1,447 @@ +//! GoonFi price state preparation. +//! +//! GoonFi publishes no IDL. Every write goes through the raw layouts in `oracle_overrides.yaml` +//! and `market_overrides.yaml`; this module exists for what those templates cannot express: the +//! price lives in a per-market oracle account that must be resolved from the market's own pointer +//! and validated by owner, and a price move is one invariant across two accounts - oracle bid and +//! ask, the market's reference band, and a freshness re-stamp. + +use std::{collections::HashMap, sync::LazyLock}; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayout, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +pub const GOONFI_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"); +/// The companion publisher program that owns every market's price oracle. +pub const GOONFI_ORACLE_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"); +pub const GOONFI_DEFAULT_MARKET: Pubkey = + Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); + +/// Read, never written, so no template declares it. +const ORACLE_POINTER_OFFSET: usize = 208; + +/// The layouts a GoonFi market and its oracle must have, taken from the manifests the raw +/// templates are written against so there is one definition of them. Built once; both manifests +/// are compiled in. +static ORACLE_LAYOUT: LazyLock = LazyLock::new(|| layout_of(PRICE_TEMPLATE)); +static MARKET_LAYOUT: LazyLock = LazyLock::new(|| layout_of(REFERENCE_TEMPLATE)); + +fn layout_of(template_id: &str) -> RawLayout { + template(&TemplateRegistry::new(), template_id) + .and_then(|template| { + template + .raw_layout + .clone() + .ok_or_else(|| SurfpoolError::internal("the GoonFi manifests carry no raw layout")) + }) + .expect("the GoonFi manifests are compiled in and always parse") +} + +const PRICE_TEMPLATE: &str = "goonfi-price"; +const REFERENCE_TEMPLATE: &str = "goonfi-reference-band"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Prices are the human pair price times 10^6, independent of mint decimals. +const PRICE_SCALE_DECIMALS: u32 = 6; + +/// All three overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// The parts of a GoonFi market a price move needs: the market account itself and the oracle it +/// points at. +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiMarket { + pub address: Pubkey, + pub oracle: Pubkey, +} + +impl GoonfiMarket { + /// The oracle the market prices from, read from the market's own pointer. Never trust a + /// caller-supplied oracle address: the oracle is 32 undiscriminated bytes, so the pointer + /// plus the owner check below are what keep a write out of a foreign account. + pub fn oracle_address(market_account: &Account) -> SurfpoolResult { + validate_goonfi_market_layout(market_account)?; + let oracle = read_pubkey(&market_account.data, ORACLE_POINTER_OFFSET)?; + if oracle == Pubkey::default() { + return Err(invalid("market carries no oracle pointer")); + } + Ok(oracle) + } + + pub fn validate( + address: Pubkey, + market_account: &Account, + oracle_account: &Account, + ) -> SurfpoolResult { + let oracle = Self::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + Ok(Self { address, oracle }) + } +} + +/// Rejects an account that is not a GoonFi market. +/// +/// The shared raw-layout guard has no owner predicate, so a foreign account of the same size +/// carrying the same magic would pass it. Every builder-made scenario comes through here, which +/// adds the ownership check the schema cannot express. +pub fn validate_goonfi_market_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_PROGRAM_ID { + return Err(invalid("market is not owned by GoonFi")); + } + MARKET_LAYOUT.guard(&account.data).map_err(invalid) +} + +/// Rejects an account that is not a GoonFi price oracle. +/// +/// The oracle is 32 bytes with no magic at all, so its guard pins only the size; the owner check +/// here is the real discriminator. +pub fn validate_goonfi_oracle_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_ORACLE_PROGRAM_ID { + return Err(invalid("oracle is not owned by the GoonFi publisher")); + } + ORACLE_LAYOUT.guard(&account.data).map_err(invalid) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiPricePreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub oracle: Pubkey, + pub price_x1e6: u64, +} + +pub fn build_goonfi_price_scenario( + market: &GoonfiMarket, + price: &str, +) -> SurfpoolResult { + let price_x1e6 = human_price_to_x1e6(price)?; + let scaled = price_x1e6.to_string(); + + let registry = TemplateRegistry::new(); + let price_template = template(®istry, PRICE_TEMPLATE)?; + let reference = template(®istry, REFERENCE_TEMPLATE)?; + let freshness = template(®istry, FRESHNESS_TEMPLATE)?; + let market_name = market.address.to_string(); + let oracle_target = AccountAddress::Pubkey(market.oracle.to_string()); + + // No fetch_before_use anywhere: the oracle and reference values are absolute targets for the + // account graph creation read, and a Play-time refetch would reinstall remote bytes over any + // local edit. + let price_override = OverrideInstance::new( + price_template.id.clone(), + PREPARATION_SLOT, + oracle_target.clone(), + ) + .with_values(HashMap::from([ + ( + "bid_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "ask_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ])) + .with_label(format!("GoonFi {market_name} price")); + + // The deployed program rejects an oracle price outside the market's reference band with + // custom error 0x24, so the band moves to the same target as one invariant. + let reference_override = OverrideInstance::new( + reference.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(market.address.to_string()), + ) + .with_values(HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!(scaled), + ), + ])) + .with_label(format!("GoonFi {market_name} reference band")); + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null takes + // the template's own lead of zero. Persisted, so the prepared price stays inside the oracle's + // staleness window however long the scenario is left running. + let freshness_override = + OverrideInstance::new(freshness.id.clone(), PREPARATION_SLOT, oracle_target) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true); + + let normalized_price = price.trim(); + let mut scenario = Scenario::new( + format!("GoonFi {market_name} at {normalized_price}"), + format!( + "Prepare GoonFi market {} to quote one base token at {normalized_price} quote tokens; no swap is sent.", + market.address + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "price-dislocation".to_string(), + ]; + scenario.add_override(price_override); + scenario.add_override(reference_override); + scenario.add_override(freshness_override); + + Ok(GoonfiPricePreparation { + scenario, + market: market.address, + oracle: market.oracle, + price_x1e6, + }) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data[offset..offset + 32] + .try_into() + .map_err(|_| invalid("market oracle bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +pub(super) fn human_price_to_x1e6(price: &str) -> SurfpoolResult { + let value = price.trim(); + let mut parts = value.split('.'); + let whole = parts.next().unwrap_or_default(); + let fractional = parts.next().unwrap_or_default(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fractional.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(invalid("price must be a positive decimal string")); + } + + // Reject rather than truncate: a seventh decimal place cannot be represented, and silently + // dropping it would prepare a different price than the caller asked for. + if fractional.len() > PRICE_SCALE_DECIMALS as usize { + return Err(invalid(format!( + "price carries more than {PRICE_SCALE_DECIMALS} decimal places, past GoonFi's 10^-6 resolution" + ))); + } + let digits = format!("{whole}{fractional}") + .parse::() + .map_err(|_| invalid("price is too large"))?; + let exponent = PRICE_SCALE_DECIMALS - fractional.len() as u32; + let scaled = 10u128 + .checked_pow(exponent) + .and_then(|power| digits.checked_mul(power)) + .ok_or_else(|| invalid("price is too large"))?; + if scaled == 0 { + return Err(invalid("price must be greater than zero")); + } + u64::try_from(scaled).map_err(|_| { + let max_price = u64::MAX / 10u64.pow(PRICE_SCALE_DECIMALS); + invalid(format!( + "price is too large for GoonFi's u64 field; a market accepts at most about {max_price} quote per base" + )) + }) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn market_account(oracle: &Pubkey) -> Account { + let mut data = vec![0; MARKET_LAYOUT.account_size]; + let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[ORACLE_POINTER_OFFSET..ORACLE_POINTER_OFFSET + 32].copy_from_slice(oracle.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn oracle_account() -> Account { + Account { + data: vec![0; ORACLE_LAYOUT.account_size], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + + fn market() -> GoonfiMarket { + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&FIXTURE_ORACLE), + &oracle_account(), + ) + .expect("valid GoonFi market") + } + + #[test] + fn builds_price_scenario_across_both_accounts() { + let market = market(); + let preparation = build_goonfi_price_scenario(&market, "99.74").unwrap(); + assert_eq!(preparation.price_x1e6, 99_740_000); + + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected price, reference-band and freshness overrides"); + }; + assert_eq!( + price.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + reference.account, + AccountAddress::Pubkey(market.address.to_string()) + ); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + price.values.get("bid_price_x1e6"), + Some(&serde_json::json!("99740000")) + ); + assert_eq!( + reference.values.get("reference_price_b_x1e6"), + Some(&serde_json::json!("99740000")) + ); + } + + /// The values are absolute targets for the creation read, so nothing refetches at Play; the + /// freshness value must stay null because the slot encoder reads a supplied number as the + /// lead rather than ignoring it. + #[test] + fn price_stays_on_the_creation_read_and_freshness_keeps_the_template_lead() { + let preparation = build_goonfi_price_scenario(&market(), "1").unwrap(); + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected exactly three overrides"); + }; + assert!(!price.fetch_before_use); + assert!(!price.persist); + assert!(!reference.fetch_before_use); + assert!(!reference.persist); + assert!(!freshness.fetch_before_use); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_prices_by_ten_to_the_sixth_regardless_of_decimals() { + for (price, expected) in [ + ("77526.523154", 77_526_523_154u64), + ("0.00841", 8_410), + ("1558.9384", 1_558_938_400), + ] { + let preparation = build_goonfi_price_scenario(&market(), price).unwrap(); + assert_eq!(preparation.price_x1e6, expected, "price {price}"); + } + } + + #[test] + fn rejects_invalid_price_and_account_inputs() { + let market = market(); + for price in [ + "0", + "-1", + "1.2.3", + "not-a-price", + "", + "0.0000001", + "1.0000009", + ] { + assert!( + build_goonfi_price_scenario(&market, price).is_err(), + "price {price} must be refused" + ); + } + + // A pathological fraction must come back as an error, never a panic or a wrapped value. + let poison = format!("0.{}1", "0".repeat(133)); + assert!(build_goonfi_price_scenario(&market, &poison).is_err()); + let long_whole = "9".repeat(60); + assert!(build_goonfi_price_scenario(&market, &long_whole).is_err()); + + let uncataloged = GoonfiMarket { + address: Pubkey::new_unique(), + oracle: Pubkey::new_unique(), + }; + let preparation = build_goonfi_price_scenario(&uncataloged, "1").unwrap(); + assert!( + preparation + .scenario + .name + .contains(&uncataloged.address.to_string()) + ); + assert_eq!(preparation.oracle, uncataloged.oracle); + + let oracle = Pubkey::new_unique(); + let wrong_owner = Account { + owner: Pubkey::new_unique(), + ..market_account(&oracle) + }; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &wrong_owner, &oracle_account()).is_err() + ); + // The raw guard cannot see the owner, which is the whole reason this check sits on top. + assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); + + let mut bad_magic = market_account(&oracle); + bad_magic.data[0] ^= 0xff; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &bad_magic, &oracle_account()).is_err() + ); + + let no_pointer = market_account(&Pubkey::default()); + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &no_pointer, &oracle_account()).is_err() + ); + + // The oracle carries no magic at all, so the owner check is its only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle_account() + }; + assert!( + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&oracle), + &foreign_oracle + ) + .is_err() + ); + assert!(ORACLE_LAYOUT.guard(&foreign_oracle.data).is_ok()); + + let truncated_oracle = Account { + data: vec![0; 16], + ..oracle_account() + }; + assert!(validate_goonfi_oracle_layout(&truncated_oracle).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/mod.rs b/crates/core/src/scenarios/protocols/mod.rs index 99f0b0967..dbcb89738 100644 --- a/crates/core/src/scenarios/protocols/mod.rs +++ b/crates/core/src/scenarios/protocols/mod.rs @@ -1 +1,2 @@ +pub mod goonfi; pub mod pump; diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index a421a3f91..6048f06d9 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -23,6 +23,12 @@ pub const METEORA_DLMM_OVERRIDES_CONTENT: &str = pub const KAMINO_V1_IDL_CONTENT: &str = include_str!("./protocols/kamino/v1/idl.json"); pub const KAMINO_V1_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/v1/overrides.yaml"); +pub const GOONFI_V1_ORACLE_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/oracle_overrides.yaml"); + +pub const GOONFI_V1_MARKET_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/market_overrides.yaml"); + pub const KAMINO_SCOPE_IDL_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/idl.json"); pub const KAMINO_SCOPE_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/overrides.yaml"); @@ -76,6 +82,7 @@ impl TemplateRegistry { default.load_raydium_overrides(); default.load_meteora_overrides(); default.load_kamino_overrides(); + default.load_goonfi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -116,6 +123,12 @@ impl TemplateRegistry { ); } + /// GoonFi writes two account shapes - the price oracle and the market that band-guards it. + pub fn load_goonfi_overrides(&mut self) { + self.load_raw_layout_overrides(GOONFI_V1_ORACLE_OVERRIDES_CONTENT, "goonfi"); + self.load_raw_layout_overrides(GOONFI_V1_MARKET_OVERRIDES_CONTENT, "goonfi"); + } + pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -516,13 +529,19 @@ mod tests { // Pyth (1) + 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) = 62 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + // + GoonFi (oracle 3 + market 1) = 66 assert_eq!( registry.count(), - 62, - "Registry should load 62 templates total" + 66, + "Registry should load 66 templates total" ); + assert!(registry.contains("goonfi-price")); + assert!(registry.contains("goonfi-stale-quote")); + assert!(registry.contains("goonfi-freshness")); + assert!(registry.contains("goonfi-reference-band")); + assert!(registry.contains("pyth-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 831432580..da32e51cb 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -3085,6 +3085,13 @@ impl SurfnetSvm { .cloned(); if let Some(template) = raw_template { let raw_layout = template.raw_layout.expect("filtered above"); + if let Err(e) = raw_layout.guard_owner(account.owner()) { + warn!( + "Raw-layout override {} refused on {}: {}", + override_instance.id, account_pubkey, e + ); + continue; + } let properties = template.properties; match raw_layout.materialize( account.data(), @@ -4529,10 +4536,6 @@ impl SurfnetSvm { Ok(fixtures) } - /// Registers a scenario for execution by scheduling its overrides - /// - /// The `slot` parameter is the base slot from which relative override slot heights are calculated. - /// If not provided, uses the current slot. pub fn register_scenario( &mut self, scenario: surfpool_types::Scenario, diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs new file mode 100644 index 000000000..9bbaaedc0 --- /dev/null +++ b/crates/core/src/tests/goonfi/mod.rs @@ -0,0 +1,1110 @@ +//! Behavioral proofs for GoonFi's oracle and market layouts against the current deployed program. +//! +//! GoonFi V2 prices swaps from a per-market oracle account owned by a companion publisher +//! program, not from the market account itself. The market account carries the pair's identities +//! (mints, vaults, oracle pointer) in cleartext plus the reference band that guards the oracle +//! price; the oracle carries bid/ask, a u32 freshness slot, and a dynamic staleness multiplier. +//! +//! Run serially against mainnet: +//! `cargo test -p surfpool-core --features integration-tests tests::goonfi -- --test-threads=1` + +use std::collections::HashMap; + +use sha2::{Digest, Sha256}; +use solana_account::Account; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_pack::Pack; +use solana_program_runtime::{ + declare_process_instruction, solana_sbpf::program::BuiltinFunctionDefinition, +}; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::{ + TemplateRegistry, + protocols::goonfi::v1::{ + GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, + }, + }, + surfnet::svm::SurfnetSvm, + tests::live, +}; + +const GOONFI_PROGRAM: &str = "goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"; +const GOONFI_PROGRAMDATA: &str = "124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s"; +const ORACLE_PROGRAMDATA: &str = "7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z"; +const GOONFI_ORACLE_PROGRAM: &str = "dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"; +const GOONFI_GLOBAL: &str = "BNrK9LpEn65QA4TyBLVSMdngW3XHj3xLfFPwGdCBv8wV"; +const JUPITER_PROGRAM: &str = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; +const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +const CURRENT_DEPLOY_SLOT: u64 = 438_563_879; +const CURRENT_ELF_SHA256: &str = "73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e"; +const ORACLE_DEPLOY_SLOT: u64 = 404_369_628; +const ORACLE_ELF_SHA256: &str = "0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695"; + +/// Deployed-program error codes, proven by the replay runs below. +const ERROR_STALE_ORACLE: &str = "Custom(21)"; +const ERROR_PRICE_OUT_OF_BAND: &str = "Custom(36)"; +const ERROR_MIN_AMOUNT_OUT: &str = "Custom(15)"; +const ERROR_INSUFFICIENT_LIQUIDITY: &str = "Custom(1)"; + +/// Oracle layout: both prices are the human pair price times 10^6, independent of mint decimals. +/// The freshness slot is 4 bytes; the u32 beside it is the decay-rate multiplier around 10^6 - +/// it scales how fast a quote degrades with age and does not move the rejection boundary. +const ORACLE_BID_OFFSET: usize = 0; +const ORACLE_ASK_OFFSET: usize = 8; +const ORACLE_SLOT_OFFSET: usize = 16; +const ORACLE_MULTIPLIER_OFFSET: usize = 20; +const ORACLE_TS_MS_OFFSET: usize = 24; + +/// Market-account fields the flows touch or read. The two reference prices band-guard the oracle; +/// the mint and oracle pointers identify the pair. +const MARKET_BASE_MINT_OFFSET: usize = 80; +const MARKET_QUOTE_MINT_OFFSET: usize = 112; +const MARKET_ORACLE_OFFSET: usize = 208; +const MARKET_REF_A_OFFSET: usize = 1712; +const MARKET_REF_B_OFFSET: usize = 1720; + +#[derive(Clone, Copy)] +struct MarketSpec { + market: &'static str, + base_vault: &'static str, + quote_vault: &'static str, + base_mint: &'static str, + quote_mint: &'static str, + oracle: &'static str, + amount_in: u64, +} + +/// The pair the captured reference swap traded, so the replay mirrors a known-good transaction. +const PRIMARY_MARKET: MarketSpec = MarketSpec { + market: "HBDaV4ndLuVe6qK1vGCXReon4B1DJKa9UrbqP8cVqywx", + base_vault: "4KDPiofhBxLMuTuvaYtMAqY6e5DnzbHLB6i7eeU239f6", + quote_vault: "DAogoedaaCcn2SzTc3yi7bWgTWYv5MwoTj6ySgw9snLS", + base_mint: "A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "vCDwWKdqPHYAP7q5zXY6xk3XC5Ct5oqCs5fdpoosPNq", + amount_in: 25_109_852, +}; + +const SOL_USDC_MARKET: MarketSpec = MarketSpec { + market: "GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC", + base_vault: "8ncU5YW1CQwvr4gs7buH57bW58e86TDau4STrCJBuz8z", + quote_vault: "EunHLeqeJKvxnCPQSytnBP63HJVk2fbHceiKKpngyAo8", + base_mint: "So11111111111111111111111111111111111111112", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3", + amount_in: 1_000_000_000, +}; + +#[derive(Clone)] +struct GoonfiFork { + spec: MarketSpec, + elf: Vec, + global: Account, + market: Account, + base_vault: Account, + quote_vault: Account, + base_mint: Account, + quote_mint: Account, + oracle: Account, +} + +declare_process_instruction!(GoonfiCpiWrapper, 1, |invoke_context| { + let instruction = { + let context = invoke_context + .transaction_context + .get_current_instruction_context()?; + let accounts = (1..context.get_number_of_instruction_accounts()) + .map(|index| { + Ok(AccountMeta { + pubkey: *context.get_key_of_instruction_account(index)?, + is_signer: context.is_instruction_account_signer(index)?, + is_writable: context.is_instruction_account_writable(index)?, + }) + }) + .collect::, solana_instruction::error::InstructionError>>()?; + Instruction { + program_id: Pubkey::from_str_const(GOONFI_PROGRAM), + accounts, + data: context.get_instruction_data().to_vec(), + } + }; + invoke_context.native_invoke_signed(instruction, &[]) +}); + +async fn fetch_accounts(addresses: &[&str]) -> Vec { + let pubkeys: Vec = addresses + .iter() + .map(|address| Pubkey::from_str_const(address)) + .collect(); + live::fetch(&pubkeys).await +} + +async fn goonfi_fork(spec: MarketSpec) -> GoonfiFork { + // The ProgramData and global accounts total near a megabyte, which the public endpoint + // refuses to return alongside the market graph. Fetch the two big slow-moving accounts + // separately and keep the price-coupled market graph in one same-slot batch. + let mut big = fetch_accounts(&[GOONFI_PROGRAMDATA, GOONFI_GLOBAL, ORACLE_PROGRAMDATA]).await; + let mut accounts = fetch_accounts(&[ + spec.market, + spec.base_vault, + spec.quote_vault, + spec.base_mint, + spec.quote_mint, + spec.oracle, + ]) + .await; + let programdata = big.remove(0); + assert_eq!(programdata.data.len(), 252_429, "ProgramData size changed"); + assert_eq!( + u64::from_le_bytes(programdata.data[4..12].try_into().unwrap()), + CURRENT_DEPLOY_SLOT, + "GoonFi was redeployed; revalidate the raw layout" + ); + let elf = programdata.data[45..].to_vec(); + assert_eq!( + hex::encode(Sha256::digest(&elf)), + CURRENT_ELF_SHA256, + "GoonFi ELF changed without a ProgramData address change" + ); + // The publisher's identity is pinned too: its oracle accounts are the price templates' write + // targets, so a redeploy there also voids the layout evidence. + let oracle_programdata = big.pop().expect("oracle programdata fetched"); + assert_eq!( + oracle_programdata.data.len(), + 557, + "oracle publisher ProgramData size changed" + ); + assert_eq!( + u64::from_le_bytes(oracle_programdata.data[4..12].try_into().unwrap()), + ORACLE_DEPLOY_SLOT, + "the oracle publisher was redeployed; revalidate the oracle layout" + ); + assert_eq!( + hex::encode(Sha256::digest(&oracle_programdata.data[45..])), + ORACLE_ELF_SHA256, + "oracle publisher ELF changed without a ProgramData address change" + ); + + GoonfiFork { + spec, + elf, + global: big.remove(0), + market: accounts.remove(0), + base_vault: accounts.remove(0), + quote_vault: accounts.remove(0), + base_mint: accounts.remove(0), + quote_mint: accounts.remove(0), + oracle: accounts.remove(0), + } +} + +fn with_controlled_inventory(mut fork: GoonfiFork) -> GoonfiFork { + // Publishers can drain live vaults to dust. Fund only the local fixture so price and age + // assertions measure those controls rather than unrelated, time-varying inventory limits. + for (address, vault, mint_address, mint) in [ + ( + fork.spec.base_vault, + &mut fork.base_vault, + fork.spec.base_mint, + &fork.base_mint, + ), + ( + fork.spec.quote_vault, + &mut fork.quote_vault, + fork.spec.quote_mint, + &fork.quote_mint, + ), + ] { + assert_eq!(vault.owner, spl_token_interface::ID); + assert_eq!(mint.owner, spl_token_interface::ID); + let mint_state = spl_token_interface::state::Mint::unpack(&mint.data) + .expect("controlled fixture mint must remain valid"); + let mut token = spl_token_interface::state::Account::unpack(&vault.data) + .expect("controlled fixture vault must remain valid"); + assert_eq!(token.mint, Pubkey::from_str_const(mint_address)); + assert_eq!(token.owner, Pubkey::from_str_const(fork.spec.market)); + let minimum_amount = 10u64 + .checked_pow(u32::from(mint_state.decimals)) + .and_then(|unit| unit.checked_mul(10_000)) + .expect("10,000 whole fixture tokens must fit u64"); + let original_amount = token.amount; + token.amount = token.amount.max(minimum_amount); + let original_data = vault.data.clone(); + spl_token_interface::state::Account::pack(token, &mut vault.data) + .expect("pack controlled fixture vault"); + if let solana_program_option::COption::Some(reserve) = token.is_native { + vault.lamports = reserve + .checked_add(token.amount) + .expect("controlled native vault funding fits u64"); + } + assert_only_ranges_changed(&original_data, &vault.data, &[(64, 72)]); + eprintln!( + "GoonFi controlled local inventory {address}: captured {original_amount}, prepared {} raw units; market, oracle and deployed ELF remain captured", + token.amount + ); + } + fork +} + +fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(owner.as_ref()); + data[64..72].copy_from_slice(&amount.to_le_bytes()); + data[108] = 1; + data +} + +fn native_token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = token_account(mint, owner, amount); + data[109..113].copy_from_slice(&1u32.to_le_bytes()); + data[113..121].copy_from_slice(&2_039_280u64.to_le_bytes()); + data +} + +fn token_amount(data: &[u8]) -> u64 { + u64::from_le_bytes(data[64..72].try_into().unwrap()) +} + +fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +fn read_u32(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) +} + +fn write_u64(data: &mut [u8], offset: usize, value: u64) { + data[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn oracle_slot(data: &[u8]) -> u64 { + u64::from(read_u32(data, ORACLE_SLOT_OFFSET)) +} + +fn scale_prices(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [ORACLE_BID_OFFSET, ORACLE_ASK_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn scale_refs(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [MARKET_REF_A_OFFSET, MARKET_REF_B_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn assert_only_ranges_changed(before: &[u8], after: &[u8], ranges: &[(usize, usize)]) { + assert_eq!(after.len(), before.len()); + for index in live::diff_indices(before, after) { + assert!( + ranges + .iter() + .any(|(start, end)| (*start..*end).contains(&index)), + "unexpected changed byte at {index}" + ); + } +} + +struct RunConfig { + amount_in: u64, + is_bid: u8, + min_amount_out: u64, + /// Slots past the oracle's snapshot update slot at which the swap executes. + clock_slot_age: u64, + /// Seconds past the oracle's snapshot publish time at which the swap executes. + clock_ts_age: i64, +} + +impl RunConfig { + fn sell(amount_in: u64) -> Self { + Self { + amount_in, + is_bid: 0, + min_amount_out: 1, + clock_slot_age: 1, + clock_ts_age: 1, + } + } + + fn buy(amount_in: u64) -> Self { + Self { + is_bid: 1, + ..Self::sell(amount_in) + } + } + + fn sell_at_age(amount_in: u64, clock_slot_age: u64) -> Self { + Self { + clock_slot_age, + ..Self::sell(amount_in) + } + } +} + +fn goonfi_run( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_full(fork, config, mutate_oracle, |_| {}) +} + +fn goonfi_run_full( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_capturing_oracle(fork, config, mutate_oracle, mutate_market) + .map(|(amount_out, _)| amount_out) +} + +/// Executes one GoonFi swap in LiteSVM against forked mainnet state: the deployed ELF, driven +/// through a wrapper builtin standing in for Jupiter, reproducing the aggregator-routed shape +/// every live swap has. Returns the fill and the oracle's post-execution bytes. +fn goonfi_run_capturing_oracle( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result<(u64, Vec), String> { + use litesvm::LiteSVM; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let program_id = Pubkey::from_str_const(GOONFI_PROGRAM); + let global_key = Pubkey::from_str_const(GOONFI_GLOBAL); + let market_key = Pubkey::from_str_const(fork.spec.market); + let base_vault_key = Pubkey::from_str_const(fork.spec.base_vault); + let quote_vault_key = Pubkey::from_str_const(fork.spec.quote_vault); + let base_mint_key = Pubkey::from_str_const(fork.spec.base_mint); + let quote_mint_key = Pubkey::from_str_const(fork.spec.quote_mint); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let token_program = Pubkey::from_str_const(TOKEN_PROGRAM); + + let mut oracle = fork.oracle.data.clone(); + mutate_oracle(&mut oracle); + let mut market = fork.market.data.clone(); + mutate_market(&mut market); + // Ages are measured from the snapshot the fork fetched, not from mutated bytes, so a + // re-stamped freshness field changes the account's age rather than moving the clock. + let oracle_update_slot = oracle_slot(&fork.oracle.data); + let oracle_ts_seconds = (read_u64(&fork.oracle.data, ORACLE_TS_MS_OFFSET) / 1_000) as i64; + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, &fork.elf) + .map_err(|error| format!("add_program: {error:?}"))?; + svm.add_builtin( + Pubkey::from_str_const(JUPITER_PROGRAM), + GoonfiCpiWrapper::register, + ); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = oracle_update_slot + config.clock_slot_age; + clock.unix_timestamp = oracle_ts_seconds + config.clock_ts_age; + svm.set_sysvar(&clock); + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|error| format!("set last restart slot: {error:?}"))?; + + let mut oracle_account = fork.oracle.clone(); + oracle_account.data = oracle; + let mut market_account = fork.market.clone(); + market_account.data = market; + for (key, account) in [ + (global_key, fork.global.clone()), + (market_key, market_account), + (base_vault_key, fork.base_vault.clone()), + (quote_vault_key, fork.quote_vault.clone()), + (base_mint_key, fork.base_mint.clone()), + (quote_mint_key, fork.quote_mint.clone()), + (oracle_key, oracle_account), + ] { + svm.set_account(key, account) + .map_err(|error| format!("set {key}: {error:?}"))?; + } + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|error| format!("airdrop: {error:?}"))?; + let user_base_key = Pubkey::new_unique(); + let user_quote_key = Pubkey::new_unique(); + let (base_funds, quote_funds) = if config.is_bid == 0 { + (config.amount_in, 0) + } else { + (0, config.amount_in) + }; + let user_account = |mint: &Pubkey, amount: u64| { + let is_native = + mint == &Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + Account { + lamports: if is_native { + amount.saturating_add(2_039_280) + } else { + 10_000_000 + }, + data: if is_native { + native_token_account(mint, &taker.pubkey(), amount) + } else { + token_account(mint, &taker.pubkey(), amount) + }, + owner: token_program, + executable: false, + rent_epoch: 0, + } + }; + svm.set_account(user_base_key, user_account(&base_mint_key, base_funds)) + .map_err(|error| format!("set user base: {error:?}"))?; + svm.set_account(user_quote_key, user_account("e_mint_key, quote_funds)) + .map_err(|error| format!("set user quote: {error:?}"))?; + + let mut data = vec![1u8, config.is_bid]; + data.extend_from_slice(&config.amount_in.to_le_bytes()); + data.extend_from_slice(&config.min_amount_out.to_le_bytes()); + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_400_000u32.to_le_bytes()); + let instructions = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + Instruction { + program_id: Pubkey::from_str_const(JUPITER_PROGRAM), + accounts: vec![ + AccountMeta::new_readonly(program_id, false), + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(market_key, false), + AccountMeta::new(user_base_key, false), + AccountMeta::new(user_quote_key, false), + AccountMeta::new(base_vault_key, false), + AccountMeta::new(quote_vault_key, false), + AccountMeta::new_readonly(base_mint_key, false), + AccountMeta::new_readonly(quote_mint_key, false), + AccountMeta::new_readonly(oracle_key, false), + AccountMeta::new_readonly(global_key, false), + AccountMeta::new_readonly( + Pubkey::from_str_const("Sysvar1nstructions1111111111111111111111111"), + false, + ), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(token_program, false), + ], + data, + }, + ]; + let mut message = solana_message::Message::new(&instructions, Some(&taker.pubkey())); + message.recent_blockhash = svm.latest_blockhash(); + let signature_count = message.header.num_required_signatures as usize; + let mut transaction = Transaction::new_unsigned(message); + transaction.signatures = vec![solana_signature::Signature::default(); signature_count]; + transaction.signatures[0] = taker.sign_message(&transaction.message.serialize()); + + svm.send_transaction(transaction) + .map_err(|error| format!("{error:?}"))?; + let destination = if config.is_bid == 0 { + user_quote_key + } else { + user_base_key + }; + let amount_out = token_amount( + &svm.get_account(&destination) + .expect("destination account") + .data, + ); + let oracle_after = svm.get_account(&oracle_key).expect("oracle account").data; + Ok((amount_out, oracle_after)) +} + +/// Forks a market by its address alone, resolving vaults, mints, and oracle from the market +/// account's own pointers. Used where a fixture market outside the two hardcoded specs is needed. +async fn fork_from_market(market: &'static str, amount_in: u64) -> GoonfiFork { + let accounts = fetch_accounts(&[market]).await; + let data = &accounts[0].data; + let field = |offset: usize| -> &'static str { + Box::leak( + Pubkey::new_from_array(data[offset..offset + 32].try_into().unwrap()) + .to_string() + .into_boxed_str(), + ) + }; + let spec = MarketSpec { + market, + base_vault: field(144), + quote_vault: field(176), + base_mint: field(MARKET_BASE_MINT_OFFSET), + quote_mint: field(MARKET_QUOTE_MINT_OFFSET), + oracle: field(MARKET_ORACLE_OFFSET), + amount_in, + }; + goonfi_fork(spec).await +} + +/// Materializes the goonfi-stale-quote template with its default lead onto the fork's live +/// oracle bytes, asserts the exact 4-byte slot it wrote, and proves the deployed program then +/// rejects the swap. This is the template's own default doing the aging, not a hand-picked age. +fn stale_template_default_rejects(fork: &GoonfiFork, amount: u64) { + let registry = TemplateRegistry::new(); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let snapshot_slot = oracle_slot(&fork.oracle.data); + let aged = stale + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &stale.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + snapshot_slot, + ) + .expect("materialize stale default"); + assert_eq!( + oracle_slot(&aged), + snapshot_slot - 2_000, + "the default lead must write exactly slot minus 2000" + ); + assert_only_ranges_changed(&fork.oracle.data, &aged, &[(16, 20)]); + assert_rejects_with( + goonfi_run(fork, RunConfig::sell(amount), |oracle| { + *oracle = aged.clone() + }), + ERROR_STALE_ORACLE, + "a quote aged by the stale template's default lead", + ); +} + +fn assert_rejects_with(result: Result, code: &str, context: &str) { + match result { + Ok(amount) => panic!("{context}: expected {code}, got a fill of {amount}"), + Err(error) => assert!( + error.contains(code), + "{context}: expected {code} in: {error}" + ), + } +} + +#[tokio::test] +async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() { + let fork = goonfi_fork(PRIMARY_MARKET).await; + let registry = TemplateRegistry::new(); + let price = registry.get("goonfi-price").expect("price template"); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let fresh = registry + .get("goonfi-freshness") + .expect("freshness template"); + let band = registry + .get("goonfi-reference-band") + .expect("reference-band template"); + + let oracle_layout = price.raw_layout.as_ref().expect("oracle raw layout"); + let market_layout = band.raw_layout.as_ref().expect("market raw layout"); + assert!(oracle_layout.guard(&fork.oracle.data).is_ok()); + assert!(market_layout.guard(&fork.market.data).is_ok()); + assert!(oracle_layout.guard(&fork.oracle.data[..16]).is_err()); + assert!(market_layout.guard(&fork.market.data[..2000]).is_err()); + let mut flipped = fork.market.data.clone(); + flipped[0] ^= 0xff; + assert!(market_layout.guard(&flipped).is_err()); + + let priced = oracle_layout + .materialize( + &fork.oracle.data, + &price.properties, + &HashMap::from([ + ("bid_price_x1e6".to_string(), serde_json::json!("123456789")), + ("ask_price_x1e6".to_string(), serde_json::json!("123456790")), + ]), + 0, + ) + .expect("materialize price"); + assert_eq!(read_u64(&priced, ORACLE_BID_OFFSET), 123_456_789); + assert_eq!(read_u64(&priced, ORACLE_ASK_OFFSET), 123_456_790); + assert_only_ranges_changed(&fork.oracle.data, &priced, &[(0, 16)]); + + // The freshness slot is 4 bytes wide: the dynamic multiplier right after it must survive. + let target_slot = 500_000_123; + for (template, label) in [(stale, "stale"), (fresh, "freshness")] { + let stamped = template + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &template.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + target_slot, + ) + .unwrap_or_else(|error| panic!("materialize {label}: {error}")); + assert_only_ranges_changed(&fork.oracle.data, &stamped, &[(16, 20)]); + assert_eq!( + read_u32(&stamped, ORACLE_MULTIPLIER_OFFSET), + read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET), + "{label} clobbered the staleness multiplier" + ); + } + + let banded = market_layout + .materialize( + &fork.market.data, + &band.properties, + &HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ]), + 0, + ) + .expect("materialize reference band"); + assert_eq!(read_u64(&banded, MARKET_REF_A_OFFSET), 123_456_789); + assert_eq!(read_u64(&banded, MARKET_REF_B_OFFSET), 123_456_789); + assert_only_ranges_changed(&fork.market.data, &banded, &[(1712, 1728)]); +} + +/// Proves the exact state the real builder prepares, end to end: `build_goonfi_price_scenario` +/// output registers and materializes through the production path, touching only its declared +/// bytes, and the deployed program then fills at the prepared price. The scenario is anchored at +/// the oracle's snapshot slot so the materialized freshness stamp matches the replay clock. +async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { + let market_key = Pubkey::from_str_const(fork.spec.market); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let market = + GoonfiMarket::validate(market_key, &fork.market, &fork.oracle).expect("validate market"); + let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); + let target = live_bid * 3 / 2; + let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); + let preparation = + build_goonfi_price_scenario(&market, &price).expect("build GoonFi price scenario"); + assert_eq!(preparation.price_x1e6, target); + + let base_slot = oracle_slot(&fork.oracle.data); + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account(market_key, fork.market.clone()) + .expect("seed GoonFi market"); + svm.inner + .set_account(oracle_key, fork.oracle.clone()) + .expect("seed GoonFi oracle"); + svm.register_scenario(preparation.scenario, Some(base_slot)) + .expect("register GoonFi scenario"); + svm.materialize_overrides_for_slot(&None, base_slot) + .await + .expect("materialize GoonFi scenario"); + + let oracle = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + let market_data = svm + .inner + .get_account(&market_key) + .expect("get market") + .expect("market present") + .data; + assert_eq!(read_u64(&oracle, ORACLE_BID_OFFSET), target); + assert_eq!(read_u64(&oracle, ORACLE_ASK_OFFSET), target); + assert_eq!(oracle_slot(&oracle), base_slot); + assert_eq!(read_u64(&market_data, MARKET_REF_A_OFFSET), target); + assert_eq!(read_u64(&market_data, MARKET_REF_B_OFFSET), target); + assert_only_ranges_changed(&fork.oracle.data, &oracle, &[(0, 20)]); + assert_only_ranges_changed(&fork.market.data, &market_data, &[(1712, 1728)]); + + // The deployed program fills at the prepared price, against the exact materialized bytes. + let baseline = + goonfi_run(fork, RunConfig::sell(fork.spec.amount_in), |_| {}).expect("baseline sell"); + let prepared = goonfi_run_full( + fork, + RunConfig::sell(fork.spec.amount_in), + |data| *data = oracle.clone(), + |data| *data = market_data.clone(), + ) + .expect("sell against the builder-prepared state"); + let expected = (u128::from(baseline) * u128::from(target) / u128::from(live_bid)) as u64; + assert!( + prepared.abs_diff(expected) <= expected / 500, + "the prepared price must set the fill: {prepared} vs ~{expected}" + ); + + // Only the persistent freshness override re-applies on the next slot. + svm.materialize_overrides_for_slot(&None, base_slot + 1) + .await + .expect("materialize persistent GoonFi freshness"); + let next = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + assert_eq!(oracle_slot(&next), base_slot + 1); + assert_eq!(read_u64(&next, ORACLE_BID_OFFSET), target); + assert_only_ranges_changed(&oracle, &next, &[(16, 20)]); +} + +#[tokio::test] +async fn goonfi_builder_scenario_materializes_and_fills_across_oracle_and_market() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + builder_prepares_and_the_program_fills(&fork).await; +} + +#[tokio::test] +async fn goonfi_price_and_reference_band_control_the_deployed_program() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("baseline sell"); + assert!(baseline > 0); + + // No-op rewrite proves the encoding round-trips; the program cannot tell the bytes moved. + let noop = goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + let restated = read_u64(oracle, ORACLE_BID_OFFSET); + write_u64(oracle, ORACLE_BID_OFFSET, restated); + }) + .expect("no-op sell"); + assert_eq!(noop, baseline); + + // Coupled halve and double move the fill linearly in both directions. + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving the price must halve the fill: {halved} * 2 vs {baseline}" + ); + let doubled = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 2, 1), + |market| scale_refs(market, 2, 1), + ) + .expect("coupled doubled sell"); + assert!( + doubled.abs_diff(baseline * 2) <= baseline / 500, + "doubling the price must double the fill: {doubled} vs 2 * {baseline}" + ); + + // Decoupled moves reject: the band guards each direction against the venue-unfavorable side. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + scale_prices(oracle, 2, 1) + }), + ERROR_PRICE_OUT_OF_BAND, + "sell with raised oracle and untouched reference band", + ); + assert_rejects_with( + goonfi_run(&fork, RunConfig::buy(100_000_000), |oracle| { + scale_prices(oracle, 1, 2) + }), + ERROR_PRICE_OUT_OF_BAND, + "buy with lowered oracle and untouched reference band", + ); + let coupled_buy = goonfi_run_full( + &fork, + RunConfig::buy(100_000_000), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved buy"); + assert!(coupled_buy > 0); + + assert_rejects_with( + goonfi_run( + &fork, + RunConfig { + min_amount_out: u64::MAX, + ..RunConfig::sell(amount) + }, + |_| {}, + ), + ERROR_MIN_AMOUNT_OUT, + "sell with an impossible min_amount_out", + ); + + // Keep the successful trade size fixed so other input limits cannot mask vault depletion. + let mut limited = fork.clone(); + write_u64(&mut limited.quote_vault.data, 64, baseline); + let exact_inventory = goonfi_run(&limited, RunConfig::sell(amount), |_| {}) + .expect("sell with exactly enough quote inventory"); + assert_eq!(exact_inventory, baseline); + + write_u64(&mut limited.quote_vault.data, 64, baseline - 1); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell with quote inventory one atomic unit below the measured output", + ); + write_u64(&mut limited.quote_vault.data, 64, 0); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell against a drained quote vault", + ); +} + +fn stamp_multiplier(data: &mut [u8], multiplier: u32) { + data[ORACLE_MULTIPLIER_OFFSET..ORACLE_MULTIPLIER_OFFSET + 4] + .copy_from_slice(&multiplier.to_le_bytes()); +} + +/// First rejection age in 15..=40 under the given multiplier, asserting fills decay +/// monotonically before it and every rejection carries the staleness error. +fn rejection_boundary(fork: &GoonfiFork, amount: u64, multiplier: u32) -> u64 { + let mut previous = u64::MAX; + let mut first_rejection = None; + for age in 15..=40 { + let result = goonfi_run(fork, RunConfig::sell_at_age(amount, age), |oracle| { + stamp_multiplier(oracle, multiplier) + }); + match result { + Ok(output) => { + assert!( + first_rejection.is_none(), + "age {age} filled after the window closed at {first_rejection:?}" + ); + assert!(output <= previous, "decay reversed at age {age}"); + previous = output; + } + Err(error) => { + assert!( + error.contains(ERROR_STALE_ORACLE), + "age {age}: expected {ERROR_STALE_ORACLE} in: {error}" + ); + first_rejection.get_or_insert(age); + } + } + } + first_rejection.expect("no rejection up to age 40") +} + +#[tokio::test] +async fn goonfi_stale_quote_decays_then_rejects_and_freshness_restores() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let fresh = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("fresh sell"); + let aged = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |_| {}).expect("aged sell"); + assert!( + aged < fresh, + "the program decays a quote with age: {aged} at age 10 vs {fresh} at age 1" + ); + + // The boundary's source is per-market and unidentified; this range is a safety canary + // around the observed value, not a fixed protocol constant. + let live_multiplier = read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET); + let boundary = rejection_boundary(&fork, amount, live_multiplier); + assert!( + (15..=35).contains(&boundary), + "rejection boundary {boundary} left the observed range" + ); + + // The multiplier at offset 20 scales the decay, not the window: at half and double the live + // value the boundary stays put, the decay rate scales with it, and the program leaves the + // oracle bytes untouched. + let mut decay_per_multiplier = Vec::new(); + for (label, numerator, denominator) in [("half", 1u64, 2u64), ("live", 1, 1), ("double", 2, 1)] + { + let multiplier = + u32::try_from(u64::from(live_multiplier) * numerator / denominator).expect("fits u32"); + let mut expected_oracle = fork.oracle.data.clone(); + stamp_multiplier(&mut expected_oracle, multiplier); + + let (at_age_1, oracle_after) = goonfi_run_capturing_oracle( + &fork, + RunConfig::sell(amount), + |oracle| stamp_multiplier(oracle, multiplier), + |_| {}, + ) + .unwrap_or_else(|error| panic!("sell at {label} multiplier: {error}")); + assert_eq!( + oracle_after, expected_oracle, + "the swap must not write the oracle ({label} multiplier)" + ); + let at_age_10 = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |oracle| { + stamp_multiplier(oracle, multiplier) + }) + .unwrap_or_else(|error| panic!("aged sell at {label} multiplier: {error}")); + decay_per_multiplier.push(at_age_1 - at_age_10); + + assert_eq!( + rejection_boundary(&fork, amount, multiplier), + boundary, + "the {label} multiplier must not move the rejection boundary" + ); + } + let [half, live, double] = decay_per_multiplier[..] else { + unreachable!() + }; + assert!( + double.abs_diff(live * 2) <= live / 25, + "doubling the multiplier must double the decay: {double} vs 2 * {live}" + ); + assert!( + (half * 2).abs_diff(live) <= live / 25, + "halving the multiplier must halve the decay: {half} * 2 vs {live}" + ); + + // The wall-clock timestamp beside the slot is not consulted. + let ts_aged = goonfi_run( + &fork, + RunConfig { + clock_ts_age: 3_600, + ..RunConfig::sell(amount) + }, + |_| {}, + ) + .expect("sell an hour of wall-clock later"); + assert_eq!(ts_aged, fresh); + + // Deep staleness rejects; re-stamping the u32 slot alone restores the quote, which is what + // the goonfi-freshness template does at every materialization. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |_| {}), + ERROR_STALE_ORACLE, + "sell at age 1000", + ); + stale_template_default_rejects(&fork, amount); + let restamped_slot = oracle_slot(&fork.oracle.data) + 1_000; + let restamped = goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |oracle| { + oracle[ORACLE_SLOT_OFFSET..ORACLE_SLOT_OFFSET + 4] + .copy_from_slice(&(restamped_slot as u32).to_le_bytes()); + }) + .expect("sell at age 1000 with a re-stamped slot"); + assert!( + restamped * 100 >= fresh * 99, + "a re-stamped quote must fill near full price: {restamped} vs {fresh}" + ); +} + +#[tokio::test] +async fn goonfi_second_market_proves_generic_price_and_staleness_layout() { + let fork = with_controlled_inventory(goonfi_fork(SOL_USDC_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("SOL/USDC sell"); + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("SOL/USDC coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving must halve on the second market too: {halved} * 2 vs {baseline}" + ); + + let bought = goonfi_run(&fork, RunConfig::buy(100_000_000), |_| {}).expect("SOL/USDC buy"); + assert!(bought > 0); + + builder_prepares_and_the_program_fills(&fork).await; + + // Well past every observed window on this market tier; the stablecoin tier's deeper windows + // are covered by the stale-template default proof below. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 200), |_| {}), + ERROR_STALE_ORACLE, + "SOL/USDC sell past the staleness window", + ); + + // The stablecoin tier fills at ages that reject every other market (USDT/USDC filled at age + // 100 live), so the stale template's -2000 default must out-age even that window. + let stable = with_controlled_inventory( + fork_from_market("EEUNhHsRoUVgJUFpkupmdF4v7uLUw1zhYLp7u9s8zFqG", 0).await, + ); + let stable_amount = 1_000_000; + let filled = goonfi_run(&stable, RunConfig::sell_at_age(stable_amount, 50), |_| {}) + .expect("USDT/USDC fills at an age that rejects every volatile market"); + assert!(filled > 0); + stale_template_default_rejects(&stable, stable_amount); +} + +#[tokio::test] +async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { + use std::collections::HashSet; + + let markets = discover_goonfi_markets(&live::client()) + .await + .expect("discover GoonFi markets through the real RPC client"); + assert!( + !markets.is_empty(), + "live GoonFi discovery returned no markets" + ); + let default = markets + .iter() + .find(|market| market.address == Pubkey::from_str_const(SOL_USDC_MARKET.market)) + .expect("live discovery must include the default SOL/USDC market"); + assert_eq!( + default.oracle, + Pubkey::from_str_const(SOL_USDC_MARKET.oracle) + ); + assert_eq!( + default.base_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.base_mint) + ); + assert_eq!( + default.quote_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.quote_mint) + ); + assert_eq!((default.base_decimals, default.quote_decimals), (9, 6)); + let mut addresses = HashSet::new(); + let mut oracles = HashSet::new(); + for market in &markets { + assert!( + addresses.insert(market.address), + "duplicate discovered market {}", + market.address + ); + assert!( + oracles.insert(market.oracle), + "duplicate discovered oracle {}", + market.oracle + ); + } + for chunk in markets.chunks(40) { + let addresses: Vec = chunk + .iter() + .flat_map(|market| [market.address, market.oracle]) + .collect(); + let accounts = live::fetch(&addresses).await; + for (discovered, accounts) in chunk.iter().zip(accounts.chunks_exact(2)) { + let validated = GoonfiMarket::validate(discovered.address, &accounts[0], &accounts[1]) + .expect("discovered market and oracle must retain their live owners and layouts"); + assert_eq!( + validated.oracle, discovered.oracle, + "live market oracle pointer changed" + ); + assert_eq!(&accounts[0].data[80..112], discovered.base_mint.as_ref()); + assert_eq!(&accounts[0].data[112..144], discovered.quote_mint.as_ref()); + } + } + eprintln!( + "GoonFi real RPC discovery verified {} unique live market/oracle pairs", + markets.len() + ); +} diff --git a/crates/core/src/tests/live.rs b/crates/core/src/tests/live.rs new file mode 100644 index 000000000..a9891ecfb --- /dev/null +++ b/crates/core/src/tests/live.rs @@ -0,0 +1,68 @@ +//! Shared plumbing for tests that read mainnet. +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::surfnet::remote::SurfnetRemoteClient; + +pub const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +pub const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +pub fn client() -> SurfnetRemoteClient { + SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ) +} + +/// Fetches the accounts in one request, so every account returned is from the same slot. +pub async fn fetch(addresses: &[Pubkey]) -> Vec { + // The public endpoint throttles and intermittently 503s, which has nothing to do with what + // the callers assert. Retry a few times with backoff so a transient refusal is not read as a + // failure. + let mut attempt = 0; + let mut errors = Vec::new(); + let results = loop { + match client() + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + { + Ok(results) => break results, + Err(error) if attempt < 4 => { + attempt += 1; + errors.push(format!("attempt {attempt}: {error}")); + tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await; + } + Err(error) => { + errors.push(format!("attempt {}: {error}", attempt + 1)); + panic!( + "failed to fetch {addresses:?} from mainnet after {} attempts: {}", + errors.len(), + errors.join("; ") + ); + } + } + }; + + results + .into_iter() + .zip(addresses) + .map(|(result, address)| { + result.map_account().unwrap_or_else(|_| { + panic!("{address} no longer exists on mainnet; the integration needs a new address") + }) + }) + .collect() +} + +/// The offsets at which two buffers differ. +pub fn diff_indices(left: &[u8], right: &[u8]) -> Vec { + left.iter() + .zip(right) + .enumerate() + .filter(|(_, (a, b))| a != b) + .map(|(index, _)| index) + .collect() +} diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index b2dd37925..b728bfbe3 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,7 +1,11 @@ +#[cfg(feature = "integration-tests")] +pub mod goonfi; pub mod helpers; pub mod integration; #[cfg(feature = "integration-tests")] pub mod kamino; +#[cfg(feature = "integration-tests")] +pub mod live; pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 0ef4ebe29..37651d608 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -21,6 +21,8 @@ rmcp = { workspace = true, features = ["transport-io", "transport-sse-server", " serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" +solana-account = { workspace = true } +solana-commitment-config = { workspace = true } solana-keypair = { workspace = true } solana-pubkey = { workspace = true } solana-signer = { workspace = true } diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 7ce4c3e08..6614db201 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -19,8 +19,14 @@ use start_surfnet::StartSurfnetResponse; use surfpool_core::{ scenarios::{ TemplateRegistry, - protocols::pump::v1::graduation_builder::{ - build_pump_graduation_scenario, pump_graduation_addresses, + protocols::{ + goonfi::v1::{ + GoonfiMarket, build_goonfi_liquidity_scenario, build_goonfi_price_scenario, + discover_goonfi_markets, vault_addresses, + }, + pump::v1::graduation_builder::{ + build_pump_graduation_scenario, pump_graduation_addresses, + }, }, }, solana_account::Account, @@ -37,6 +43,48 @@ use crate::helpers::find_next_available_surfnet_port; mod set_token_account; mod start_surfnet; +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListGoonfiMarketsParams { + #[schemars(description = "Port of the selected local Surfnet RPC; default 8899.")] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateGoonfiPriceScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "The price of one base token in quote tokens, as a positive decimal string such as \"99.74\". Not atomic units: GoonFi prices are decimals-independent." + )] + pub price: String, + #[schemars( + description = "The port of the target running local surfnet instance (e.g., 8899, 18899, 28899, etc.). Omit to use the default port, 8899." + )] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateGoonfiLiquidityScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "Base vault liquidity to keep, in basis points: 0 drains the base vault so swaps are rejected for insufficient liquidity (0x1), 10000 leaves it unchanged. Defaults to 0." + )] + pub base_remaining_bps: Option, + #[schemars( + description = "Quote vault liquidity to keep, in basis points: 0 drains the quote vault, 10000 leaves it unchanged. Defaults to 0." + )] + pub quote_remaining_bps: Option, + #[schemars( + description = "The port of the target running local surfnet instance (e.g., 8899, 18899, 28899, etc.). Omit to use the default port, 8899." + )] + pub surfnet_port: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct StartSurfnetParams { #[schemars( @@ -399,6 +447,38 @@ impl Surfpool { .collect()) } + async fn fetch_goonfi_market( + &self, + address: Option<&str>, + surfnet_port: Option, + ) -> Result { + let market_address = match address.map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => Pubkey::from_str(value) + .map_err(|error| format!("Invalid GoonFi market pubkey: {error}"))?, + }; + let market_account = self + .fetch_surfnet_accounts(surfnet_port, &[market_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; + let oracle_address = + GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; + let oracle_account = self + .fetch_surfnet_accounts(surfnet_port, &[oracle_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi oracle {oracle_address} was not found"))?; + GoonfiMarket::validate(market_address, &market_account, &oracle_account) + .map_err(|error| error.to_string()) + } + async fn stage_scenario(&self, scenario: Scenario) -> Result { let endpoint = format!( "http://127.0.0.1:{}/v1/scenarios", @@ -1001,6 +1081,141 @@ impl Surfpool { self.stage_scenario(preparation.scenario).await } + #[tool( + description = "Lists GoonFi markets discovered from program accounts on the selected Surfnet. Returns market and oracle addresses, pair labels, and base/quote mint identities and decimals. Validates market, oracle and mint accounts. Use address for scenario creation and oracle for oracle templates. Unknown symbols use full mint addresses." + )] + async fn list_goonfi_markets( + &self, + Parameters(params): Parameters, + ) -> Result { + let port = params.surfnet_port.unwrap_or(DEFAULT_RPC_PORT); + let client = SurfnetRemoteClient::new(format!("http://127.0.0.1:{port}")); + let markets = match discover_goonfi_markets(&client).await { + Ok(markets) => markets, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let markets = markets + .iter() + .map(|market| { + serde_json::json!({ + "address": market.address.to_string(), + "oracle": market.oracle.to_string(), + "label": market.label(), + "baseMint": market.base_mint.to_string(), + "quoteMint": market.quote_mint.to_string(), + "baseDecimals": market.base_decimals, + "quoteDecimals": market.quote_decimals, + }) + }) + .collect::>(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({"count": markets.len(), "markets": markets}).to_string(), + )])) + } + + #[tool( + description = "Creates one editable GoonFi price scenario for a live market. Reads the market account from the running surfnet, resolves its price oracle by the market's own pointer, and moves the oracle bid/ask together with the market's reference band while keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_price_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market = match self + .fetch_goonfi_market(params.market.as_deref(), params.surfnet_port) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let preparation = match build_goonfi_price_scenario(&market, ¶ms.price) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + + #[tool( + description = "Creates one editable GoonFi liquidity-drain scenario for a live market. Reads the market from the running surfnet, resolves its two token vaults by the market's own pointers, and scales each vault balance to the requested basis points (0 drains it so a swap is rejected for insufficient liquidity, 10000 leaves it unchanged), keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_liquidity_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market_address = match params.market.as_deref().map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => match Pubkey::from_str(value) { + Ok(market) => market, + Err(error) => { + return Ok(scenario_tool_error(format!( + "Invalid GoonFi market pubkey: {error}" + ))); + } + }, + }; + let market_account = match self + .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) + .await + { + Ok(mut accounts) => match accounts.remove(0) { + Some(account) => account, + None => { + return Ok(scenario_tool_error(format!( + "GoonFi market account {market_address} was not found" + ))); + } + }, + Err(error) => return Ok(scenario_tool_error(error)), + }; + // Vaults and oracle are read from the market's own pointers, never taken from the caller. + let [base_vault, quote_vault] = match vault_addresses(&market_account) { + Ok(addresses) => addresses, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let oracle = match GoonfiMarket::oracle_address(&market_account) { + Ok(oracle) => oracle, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let referenced = match self + .fetch_surfnet_accounts(params.surfnet_port, &[base_vault, quote_vault, oracle]) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let account = |index: usize, name: &str| { + referenced[index] + .as_ref() + .ok_or_else(|| format!("GoonFi {name} account was not found")) + }; + let (base_account, quote_account, oracle_account) = match ( + account(0, "base vault"), + account(1, "quote vault"), + account(2, "oracle"), + ) { + (Ok(base), Ok(quote), Ok(oracle)) => (base, quote, oracle), + (Err(error), ..) | (_, Err(error), _) | (.., Err(error)) => { + return Ok(scenario_tool_error(error)); + } + }; + let preparation = match build_goonfi_liquidity_scenario( + market_address, + &market_account, + base_account, + quote_account, + oracle_account, + params.base_remaining_bps.unwrap_or(0), + params.quote_remaining_bps.unwrap_or(0), + ) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + #[tool( description = "Fetches ALL available override templates. MUST be called before create_scenario to get valid templateId values and property names. Constants are summarized as {label, description, optionsCount} - resolve an actual option value with search_constant_options." )] @@ -1315,6 +1530,43 @@ mod tests { }) } + #[tokio::test] + async fn goonfi_price_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_price_scenario(Parameters(CreateGoonfiPriceScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + price: "99.74".to_string(), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + + #[tokio::test] + async fn goonfi_liquidity_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_liquidity_scenario(Parameters(CreateGoonfiLiquidityScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + base_remaining_bps: Some(0), + quote_remaining_bps: Some(0), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + #[tokio::test] async fn get_override_templates_summarizes_constants_instead_of_inlining_options() { let surfpool = Surfpool::new(); diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index fab64d27b..d4c8e293b 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1032,11 +1032,20 @@ pub enum RawEncoding { /// A base58 pubkey, written as 32 bytes. Bytes32, /// The slot the override materializes at, plus `lead` (may be negative). + /// + /// `width` is the byte width of the on-chain slot field: 8, or 4 for programs that store + /// slots as u32 next to unrelated bytes. Only those two widths are accepted. Slot { lead: i64, + #[serde(default = "default_slot_width")] + width: usize, }, } +fn default_slot_width() -> usize { + 8 +} + impl RawEncoding { /// Byte width of this encoding. pub fn width(&self) -> usize { @@ -1044,7 +1053,8 @@ impl RawEncoding { RawEncoding::U8 => 1, RawEncoding::U16 => 2, RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, - RawEncoding::U64 | RawEncoding::I64 | RawEncoding::Slot { .. } => 8, + RawEncoding::U64 | RawEncoding::I64 => 8, + RawEncoding::Slot { width, .. } => *width, RawEncoding::U128 | RawEncoding::I128 => 16, RawEncoding::Bytes32 => 32, } @@ -1107,7 +1117,7 @@ impl RawEncoding { .to_bytes() .to_vec() } - RawEncoding::Slot { lead } => { + RawEncoding::Slot { lead, width } => { let lead = match value { serde_json::Value::Null => *lead, _ => { @@ -1123,7 +1133,14 @@ impl RawEncoding { } else { target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0) }; - slot.to_le_bytes().to_vec() + match width { + 8 => slot.to_le_bytes().to_vec(), + 4 => u32::try_from(slot) + .map_err(|_| format!("slot {slot} does not fit a 4-byte slot field"))? + .to_le_bytes() + .to_vec(), + other => return Err(format!("slot width must be 4 or 8, not {other}")), + } } }) } @@ -1156,6 +1173,10 @@ pub struct RawLayout { /// Optional type tag. Omit for programs that have none. #[serde(default, skip_serializing_if = "Option::is_none")] pub magic: Option, + /// Base58 program id that must own the account. The byte guard cannot see the owner, so + /// without this a foreign account of the same size and magic passes a raw write. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, } impl RawLayout { @@ -1252,6 +1273,22 @@ impl RawLayout { } /// Writes `values` into a copy of `data` using each property's offset and encoding. + /// Rejects an account owned by the wrong program, when the layout names one. Split from + /// [`RawLayout::guard`] because the byte guard has no access to the owner. + pub fn guard_owner(&self, owner: &Pubkey) -> Result<(), String> { + let Some(required) = &self.owner else { + return Ok(()); + }; + let required = Pubkey::from_str(required) + .map_err(|e| format!("raw layout owner '{required}' is not a valid pubkey: {e}"))?; + if owner != &required { + return Err(format!( + "account owner {owner} is not the layout's program {required}" + )); + } + Ok(()) + } + pub fn materialize( &self, data: &[u8], @@ -1630,51 +1667,123 @@ mod tests { assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); // The supplied value is the lead, so one property covers live and stale. - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-5), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 495); // The manifest lead is the default, used when no value is given. - let bytes = RawEncoding::Slot { lead: -1 } + let bytes = RawEncoding::Slot { lead: -1, width: 8 } .encode(&json!(null), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); // A lead that would go below zero clamps rather than wrapping. - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-10), 3) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); // Slot is a u64. Values above i64::MAX must not wrap through a signed cast and become zero. let large_slot = i64::MAX as u64 + 1; - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), large_slot) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-1), u64::MAX) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX - 1); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), u64::MAX) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); - let err = RawEncoding::Slot { lead: 0 } + let err = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(1), u64::MAX) .expect_err("a positive lead must not wrap past u64::MAX"); assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); } + #[test] + fn slot_width_defaults_to_eight_and_narrows_to_four() { + use super::RawEncoding; + + // Manifests that spell no width keep the historical 8-byte slot bytes exactly. + let parsed: RawEncoding = serde_json::from_value(json!({"slot": {"lead": -20}})).unwrap(); + assert_eq!( + parsed, + RawEncoding::Slot { + lead: -20, + width: 8 + } + ); + assert_eq!( + parsed.encode(&json!(null), 500).unwrap(), + 480u64.to_le_bytes().to_vec() + ); + + let narrow: RawEncoding = + serde_json::from_value(json!({"slot": {"lead": 0, "width": 4}})).unwrap(); + assert_eq!(narrow.width(), 4); + assert_eq!( + narrow.encode(&json!(null), 500).unwrap(), + 500u32.to_le_bytes().to_vec() + ); + + let err = narrow + .encode(&json!(null), u64::from(u32::MAX) + 1) + .expect_err("a slot past u32::MAX must not be truncated"); + assert!(err.contains("4-byte"), "unexpected error: {err}"); + + let err = RawEncoding::Slot { lead: 0, width: 2 } + .encode(&json!(null), 500) + .expect_err("only widths 4 and 8 exist"); + assert!(err.contains("must be 4 or 8"), "unexpected error: {err}"); + } + + #[test] + fn raw_layout_owner_predicate_rejects_the_wrong_program() { + use super::{Pubkey, RawLayout}; + + let program = Pubkey::new_unique(); + let layout = RawLayout { + account_size: 32, + magic: None, + owner: Some(program.to_string()), + }; + assert!(layout.guard_owner(&program).is_ok()); + let err = layout + .guard_owner(&Pubkey::new_unique()) + .expect_err("a foreign owner must be refused"); + assert!( + err.contains("is not the layout's program"), + "unexpected error: {err}" + ); + + // No owner in the layout keeps the historical behavior: any owner passes. + let open = RawLayout { + account_size: 32, + magic: None, + owner: None, + }; + assert!(open.guard_owner(&Pubkey::new_unique()).is_ok()); + + let broken = RawLayout { + account_size: 32, + magic: None, + owner: Some("not-a-pubkey".to_string()), + }; + assert!(broken.guard_owner(&program).is_err()); + } + #[test] fn raw_layout_rejects_writes_past_the_end_of_the_account() { use super::{Property, RawEncoding, RawLayout}; @@ -1682,6 +1791,7 @@ mod tests { let layout = RawLayout { account_size: 16, magic: None, + owner: None, }; let mut property = Property::field("tail".to_string()); property.offset = Some(12); @@ -1704,6 +1814,7 @@ mod tests { let layout = RawLayout { account_size: 64, magic: None, + owner: None, }; let mut property = Property::field("ticks".to_string()); property.offset = Some(4); @@ -1747,6 +1858,7 @@ mod tests { let layout = RawLayout { account_size: 32, magic: None, + owner: None, }; let mut property = Property::field("ticks".to_string()); property.offset = Some(4); From ab9417bd58f9b30356972d27244860a687c8cda7 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:37:58 +0300 Subject: [PATCH 05/20] fix(goonfi): address review findings - Catalog discovery skips a market that fails validation with a warning instead of aborting the whole scan, so one obsolete market can no longer hide every valid one. An empty catalog from a program that does own markets stays an error. - Vault balances are read through TokenAccount::unpack and checked against the market's own mint: an owner-and-length check also admits an 82-byte mint, whose bytes at the amount offset would be misread as a balance. - GoonfiMarket is non_exhaustive, so a market and its oracle can only be paired through validate, which reads the oracle from the market's own pointer. --- .../protocols/goonfi/v1/liquidity.rs | 120 +++++++++++++----- .../scenarios/protocols/goonfi/v1/markets.rs | 52 +++++--- .../src/scenarios/protocols/goonfi/v1/mod.rs | 4 +- .../scenarios/protocols/goonfi/v1/price.rs | 5 + 4 files changed, 126 insertions(+), 55 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index adf3f58bd..c02fdae3a 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -16,6 +16,7 @@ use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenari use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, + types::TokenAccount, }; use super::{ @@ -27,8 +28,6 @@ const BASE_MINT_OFFSET: usize = 80; const QUOTE_MINT_OFFSET: usize = 112; const BASE_VAULT_OFFSET: usize = 144; const QUOTE_VAULT_OFFSET: usize = 176; -/// The SPL token account amount field. -const AMOUNT_OFFSET: usize = 64; const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; @@ -95,11 +94,10 @@ pub fn build_goonfi_liquidity_scenario( let oracle = GoonfiMarket::oracle_address(market_account)?; validate_goonfi_oracle_layout(oracle_account)?; - let base_amount = vault_amount(base_vault_account)?; - let quote_amount = vault_amount(quote_vault_account)?; - let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let base_amount = vault_amount(base_vault_account, "base", &base_mint)?; + let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint)?; let label = market_label(&base_mint, "e_mint); let registry = TemplateRegistry::new(); @@ -168,18 +166,27 @@ pub fn build_goonfi_liquidity_scenario( }) } -/// The SPL token vaults are 32 undiscriminated-looking bytes at the front; the owner check is the -/// real discriminator that keeps a balance write out of a foreign account. -fn vault_amount(account: &Account) -> SurfpoolResult { +/// Reads a vault balance, proving first that the account really is that market's token vault. +/// +/// An owner-and-length check is not enough: a mint is also owned by the token program and is long +/// enough to read an amount out of, so it would pass and its bytes would be misread as a balance. +/// Unpacking rejects anything that is not a token account, and the mint comparison ties the vault +/// to the side of the market it is supposed to hold. +fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> SurfpoolResult { if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { - return Err(invalid("vault is not owned by a supported token program")); + return Err(invalid(format!( + "{side} vault is not owned by a supported token program" + ))); } - let bytes: [u8; 8] = account - .data - .get(AMOUNT_OFFSET..AMOUNT_OFFSET + 8) - .and_then(|slice| slice.try_into().ok()) - .ok_or_else(|| invalid("vault is too small to be an SPL token account"))?; - Ok(u64::from_le_bytes(bytes)) + let vault = TokenAccount::unpack(&account.data) + .map_err(|error| invalid(format!("{side} vault is not a token account: {error}")))?; + if vault.mint() != *expected_mint { + return Err(invalid(format!( + "{side} vault holds mint {} but the market's {side} mint is {expected_mint}", + vault.mint() + ))); + } + Ok(vault.amount()) } fn remaining_label(bps: u16) -> String { @@ -230,9 +237,14 @@ mod tests { } } - fn vault(amount: u64) -> Account { + fn vault(mint: &Pubkey, amount: u64) -> Account { + const AMOUNT_OFFSET: usize = 64; + const STATE_OFFSET: usize = 108; let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(Pubkey::new_unique().as_ref()); data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); + data[STATE_OFFSET] = 1; Account { data, owner: spl_token_interface::ID, @@ -256,8 +268,8 @@ mod tests { let preparation = build_goonfi_liquidity_scenario( market, &market_account(&base_vault, "e_vault), - &vault(2_441_078_070_812), - &vault(216_136_231_615), + &vault(&WSOL, 2_441_078_070_812), + &vault(&USDC, 216_136_231_615), &oracle(), 0, 0, @@ -267,15 +279,15 @@ mod tests { assert_eq!(preparation.base_vault, base_vault); assert_eq!(preparation.quote_vault, quote_vault); // A friendly pair label, not the raw market pubkey. - assert_eq!( - preparation.scenario.name, - "GoonFi SOL/USDC liquidity drain" - ); + assert_eq!(preparation.scenario.name, "GoonFi SOL/USDC liquidity drain"); let [base, quote, freshness] = &preparation.scenario.overrides[..] else { panic!("expected base drain, quote drain and freshness overrides"); }; assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); - assert_eq!(quote.account, AccountAddress::Pubkey(quote_vault.to_string())); + assert_eq!( + quote.account, + AccountAddress::Pubkey(quote_vault.to_string()) + ); assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); assert!(!base.fetch_before_use); @@ -298,8 +310,8 @@ mod tests { let preparation = build_goonfi_liquidity_scenario( Pubkey::new_unique(), &market_account(&base_vault, "e_vault), - &vault(1_000), - &vault(999), + &vault(&WSOL, 1_000), + &vault(&USDC, 999), &oracle(), 2_500, FULL_BPS, @@ -326,8 +338,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), 10_001, 0 @@ -338,8 +350,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), FULL_BPS, FULL_BPS @@ -356,8 +368,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &foreign_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &oracle(), 0, 0 @@ -368,14 +380,14 @@ mod tests { // A vault not owned by a token program is not a real vault. let foreign_vault = Account { owner: Pubkey::new_unique(), - ..vault(1) + ..vault(&WSOL, 1) }; assert!( build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, &foreign_vault, - &vault(1), + &vault(&USDC, 1), &oracle(), 0, 0 @@ -392,8 +404,8 @@ mod tests { build_goonfi_liquidity_scenario( Pubkey::new_unique(), &good_market, - &vault(1), - &vault(1), + &vault(&WSOL, 1), + &vault(&USDC, 1), &foreign_oracle, 0, 0 @@ -402,6 +414,44 @@ mod tests { ); } + /// An owner-and-length check would pass a mint: it is token-program-owned and long enough to + /// misread an amount out of. Unpacking plus the mint comparison is what rejects it. + #[test] + fn rejects_a_vault_that_is_not_this_markets_token_account() { + let market = market_account(&Pubkey::new_unique(), &Pubkey::new_unique()); + let mint_account = Account { + data: vec![0u8; 82], + owner: spl_token_interface::ID, + ..Account::default() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &mint_account, + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A real token account holding the other side's mint is refused as well. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &vault(&USDC, 1), + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + } + #[test] fn resolves_vault_addresses_from_the_market() { let base_vault = Pubkey::new_unique(); diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index e4f4a6ba3..b8c71c728 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -121,19 +121,24 @@ pub async fn discover_goonfi_markets( ) .await? .into_result()?; - let accounts = accounts - .into_iter() - .map(|(address, encoded)| { - let account: Account = encoded.to_account().ok_or_else(|| { - SurfpoolError::internal(format!("Could not decode GoonFi market {address}")) - })?; - market_references(&account)?; - Ok((address, account)) - }) - .collect::>>()?; + // One obsolete or malformed market must not hide every valid one, so a market that fails + // validation is skipped with a warning and the rest of the catalog is still returned. This is + // the same warn-and-continue rule the materializer applies per override. + let candidates = accounts.len(); + let mut retained = Vec::new(); let mut addresses = Vec::new(); - for (_, account) in &accounts { - addresses.extend(market_references(account)?); + for (address, encoded) in accounts { + let Some(account) = encoded.to_account() else { + warn!("Skipping GoonFi market {address}: its account data could not be decoded"); + continue; + }; + match market_references(&account) { + Ok(references) => { + addresses.extend(references); + retained.push((address, account)); + } + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } } addresses.sort_unstable(); addresses.dedup(); @@ -143,13 +148,26 @@ pub async fn discover_goonfi_markets( .get_multiple_accounts(batch, CommitmentConfig::confirmed()) .await?; for (address, account) in batch.iter().zip(fetched) { - references.insert(*address, account.map_account()?); + // A reference the fork cannot serve disqualifies only the markets pointing at it, + // which `resolve_market` reports below. + if let Ok(account) = account.map_account() { + references.insert(*address, account); + } } } - let mut markets = accounts - .iter() - .map(|(address, account)| resolve_market(*address, account, &references)) - .collect::>>()?; + let mut markets = Vec::new(); + for (address, account) in &retained { + match resolve_market(*address, account, &references) { + Ok(market) => markets.push(market), + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } + } + // An empty catalog from a program that does own markets is a failure, not a partial result. + if markets.is_empty() && candidates > 0 { + return Err(SurfpoolError::internal(format!( + "none of the {candidates} discovered GoonFi markets validated; the integration needs a refresh" + ))); + } markets.sort_by_cached_key(|market| { ( market.address != GOONFI_DEFAULT_MARKET, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index c788b126d..b0852c772 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -2,9 +2,7 @@ mod liquidity; mod markets; mod price; -pub use liquidity::{ - GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses, -}; +pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses}; pub use price::{ GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 61cda2863..43d731d81 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -57,7 +57,12 @@ const PREPARATION_SLOT: u64 = 0; /// The parts of a GoonFi market a price move needs: the market account itself and the oracle it /// points at. +/// +/// `non_exhaustive` so the pair can only be built through `validate`, which reads the oracle from +/// the market's own pointer. A caller assembling the two fields independently could otherwise +/// point a price move at one market's band and an unrelated market's oracle. #[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] pub struct GoonfiMarket { pub address: Pubkey, pub oracle: Pubkey, From c3da371e39c37bcf49bef301f2009a46c383e26e Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 9 Sep 2026 16:52:27 +0300 Subject: [PATCH 06/20] fix(goonfi): make the validated market pair unforgeable `non_exhaustive` only blocked building a GoonfiMarket from scratch outside the crate; the fields stayed public, so a caller could still re-point a validated pair's oracle. Making both private leaves `validate` as the only way to pair a market with an oracle, and it reads that oracle from the market's own pointer. --- .../core/src/scenarios/protocols/goonfi/v1/price.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 43d731d81..d005c9703 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -58,14 +58,15 @@ const PREPARATION_SLOT: u64 = 0; /// The parts of a GoonFi market a price move needs: the market account itself and the oracle it /// points at. /// -/// `non_exhaustive` so the pair can only be built through `validate`, which reads the oracle from -/// the market's own pointer. A caller assembling the two fields independently could otherwise -/// point a price move at one market's band and an unrelated market's oracle. +/// The two are private so the pair can only be built through `validate`, which reads the oracle +/// from the market's own pointer. Public fields would let a caller assemble the pair from scratch +/// or re-point a validated one, aiming a price move at one market's reference band and an +/// unrelated market's oracle - a combination the deployed program rejects with 0x24 at best, and +/// silently misprices at worst. #[derive(Clone, Debug, PartialEq)] -#[non_exhaustive] pub struct GoonfiMarket { - pub address: Pubkey, - pub oracle: Pubkey, + address: Pubkey, + oracle: Pubkey, } impl GoonfiMarket { From 1bf68276758ee107059c61f06db2105cf9b6817f Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 10 Sep 2026 07:26:59 +0300 Subject: [PATCH 07/20] fix(goonfi): expose the validated market pair through read-only getters The fields are private, so a caller that legitimately needs to know which market and oracle a validated pair refers to had no way to ask. --- crates/core/src/scenarios/protocols/goonfi/v1/price.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index d005c9703..5cfd72601 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -91,6 +91,15 @@ impl GoonfiMarket { validate_goonfi_oracle_layout(oracle_account)?; Ok(Self { address, oracle }) } + + /// Read-only: the pair is fixed at validation so a caller can inspect it but not re-point it. + pub fn address(&self) -> Pubkey { + self.address + } + + pub fn oracle(&self) -> Pubkey { + self.oracle + } } /// Rejects an account that is not a GoonFi market. From c3f58315412f03b8e5393570a2cb55bbb05028b9 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 11 Sep 2026 10:44:36 +0300 Subject: [PATCH 08/20] fix(goonfi): tie a market's address to the bytes it was fetched with A market account does not record its own address, so `validate` had to take the caller's word that the address and the data describe the same market. Its vaults do record it: a GoonFi vault is a token account whose authority is the market. `validate` now takes the base vault the market itself names and compares that authority, the same shape of cross-check the pump graduation builder makes through its curve vault's mint. The liquidity builder applies it too, from accounts it already holds. Neither path costs an extra round trip: discovery carries the base vault alongside the mints and oracle so it batches with them, and the MCP market fetch reads it in the same call as the oracle. Verified against all 33 live markets - every one names its market as the base vault's authority. --- .../protocols/goonfi/v1/liquidity.rs | 45 +++++++--- .../scenarios/protocols/goonfi/v1/markets.rs | 31 +++++-- .../src/scenarios/protocols/goonfi/v1/mod.rs | 2 +- .../scenarios/protocols/goonfi/v1/price.rs | 89 +++++++++++++++++-- crates/core/src/tests/goonfi/mod.rs | 29 ++++-- crates/mcp/src/surfpool/mod.rs | 27 ++++-- 6 files changed, 186 insertions(+), 37 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index c02fdae3a..07d131024 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -21,6 +21,7 @@ use crate::{ use super::{ GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, + verify_market_address, }; /// Read, never written, so no template declares them. @@ -91,6 +92,7 @@ pub fn build_goonfi_liquidity_scenario( } let [base_vault, quote_vault] = vault_addresses(market_account)?; + verify_market_address(market, base_vault_account)?; let oracle = GoonfiMarket::oracle_address(market_account)?; validate_goonfi_oracle_layout(oracle_account)?; @@ -237,12 +239,15 @@ mod tests { } } + /// The market every fixture below belongs to; its vaults name it as their authority. + const MARKET: Pubkey = Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); + fn vault(mint: &Pubkey, amount: u64) -> Account { const AMOUNT_OFFSET: usize = 64; const STATE_OFFSET: usize = 108; let mut data = vec![0u8; 165]; data[0..32].copy_from_slice(mint.as_ref()); - data[32..64].copy_from_slice(Pubkey::new_unique().as_ref()); + data[32..64].copy_from_slice(MARKET.as_ref()); data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); data[STATE_OFFSET] = 1; Account { @@ -264,7 +269,7 @@ mod tests { fn drains_both_vaults_and_keeps_the_quote_fresh() { let base_vault = Pubkey::new_unique(); let quote_vault = Pubkey::new_unique(); - let market = Pubkey::new_unique(); + let market = MARKET; let preparation = build_goonfi_liquidity_scenario( market, &market_account(&base_vault, "e_vault), @@ -308,7 +313,7 @@ mod tests { let base_vault = Pubkey::new_unique(); let quote_vault = Pubkey::new_unique(); let preparation = build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &market_account(&base_vault, "e_vault), &vault(&WSOL, 1_000), &vault(&USDC, 999), @@ -336,7 +341,7 @@ mod tests { // Out of range and a no-op leave nothing to prepare. assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -348,7 +353,7 @@ mod tests { ); assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -366,7 +371,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &foreign_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -384,7 +389,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &foreign_vault, &vault(&USDC, 1), @@ -402,7 +407,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &good_market, &vault(&WSOL, 1), &vault(&USDC, 1), @@ -426,7 +431,7 @@ mod tests { }; assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &market, &mint_account, &vault(&USDC, 1), @@ -440,7 +445,7 @@ mod tests { // A real token account holding the other side's mint is refused as well. assert!( build_goonfi_liquidity_scenario( - Pubkey::new_unique(), + MARKET, &market, &vault(&USDC, 1), &vault(&USDC, 1), @@ -452,6 +457,26 @@ mod tests { ); } + /// The market account carries no self-address, so the vault's authority is what ties the + /// requested market to these bytes. + #[test] + fn rejects_a_market_address_that_does_not_hold_the_vault() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market_account(&base_vault, "e_vault), + &vault(&WSOL, 1), + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + } + #[test] fn resolves_vault_addresses_from_the_market() { let base_vault = Pubkey::new_unique(); diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index b8c71c728..0a73ee217 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -17,7 +17,7 @@ use crate::{ types::MintAccount, }; -use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket}; +use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket, vault_addresses}; #[derive(Debug, PartialEq)] pub struct GoonfiDiscoveredMarket { @@ -51,16 +51,19 @@ pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { format!("{}/{}", symbol(base_mint), symbol(quote_mint)) } -fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 3]> { +/// The accounts a market points at: its two mints, its oracle, and its base vault. The vault comes +/// along because it is what proves the market's address matches these bytes. +fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 4]> { let oracle = GoonfiMarket::oracle_address(account)?; let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); let quote = Pubkey::new_from_array(account.data[112..144].try_into().unwrap()); + let [base_vault, _] = vault_addresses(account)?; if base == Pubkey::default() || quote == Pubkey::default() || base == quote { return Err(SurfpoolError::internal( "GoonFi market has invalid mint identities", )); } - Ok([base, quote, oracle]) + Ok([base, quote, oracle, base_vault]) } fn mint_decimals(account: &Account) -> SurfpoolResult { @@ -77,13 +80,13 @@ fn resolve_market( account: &Account, references: &HashMap, ) -> SurfpoolResult { - let [base, quote, oracle] = market_references(account)?; + let [base, quote, oracle, base_vault] = market_references(account)?; let required = |address: &Pubkey| { references.get(address).ok_or_else(|| { SurfpoolError::internal(format!("GoonFi referenced account {address} was not found")) }) }; - GoonfiMarket::validate(address, account, required(&oracle)?)?; + GoonfiMarket::validate(address, account, required(&base_vault)?, required(&oracle)?)?; Ok(GoonfiDiscoveredMarket { address, oracle, @@ -190,6 +193,8 @@ mod tests { let base = Pubkey::new_unique(); let quote = Pubkey::new_unique(); let oracle = Pubkey::new_unique(); + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); let registry = TemplateRegistry::new(); let layout = registry .get("goonfi-reference-band") @@ -206,6 +211,8 @@ mod tests { market.data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); market.data[80..112].copy_from_slice(base.as_ref()); market.data[112..144].copy_from_slice(quote.as_ref()); + market.data[144..176].copy_from_slice(base_vault.as_ref()); + market.data[176..208].copy_from_slice(quote_vault.as_ref()); market.data[208..240].copy_from_slice(oracle.as_ref()); let mint = |decimals| { let mut account = Account { @@ -221,12 +228,24 @@ mod tests { .pack_into_slice(&mut account.data); account }; + let vault = { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(base.as_ref()); + data[32..64].copy_from_slice(address.as_ref()); + data[108] = 1; + Account { + owner: spl_token_interface::ID, + data, + ..Account::default() + } + }; ( address, market, HashMap::from([ (base, mint(9)), (quote, mint(6)), + (base_vault, vault), ( oracle, Account { @@ -279,7 +298,7 @@ mod tests { #[test] fn goonfi_discovery_rejects_missing_or_invalid_referenced_accounts() { let (address, account, references) = fixture(); - let [base, _, oracle] = market_references(&account).unwrap(); + let [base, _, oracle, _] = market_references(&account).unwrap(); for invalid in 0..5 { let mut references = references.clone(); match invalid { diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index b0852c772..db9eac150 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -7,7 +7,7 @@ pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, pub use price::{ GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, - validate_goonfi_oracle_layout, + validate_goonfi_oracle_layout, verify_market_address, }; pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 5cfd72601..651b512c6 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -15,6 +15,7 @@ use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayo use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, + types::TokenAccount, }; pub const GOONFI_PROGRAM_ID: Pubkey = @@ -82,12 +83,16 @@ impl GoonfiMarket { Ok(oracle) } + /// `base_vault_account` must be the account at the base vault the market itself names; it is + /// what proves `address` belongs to `market_account` (see `verify_market_address`). pub fn validate( address: Pubkey, market_account: &Account, + base_vault_account: &Account, oracle_account: &Account, ) -> SurfpoolResult { let oracle = Self::oracle_address(market_account)?; + verify_market_address(address, base_vault_account)?; validate_goonfi_oracle_layout(oracle_account)?; Ok(Self { address, oracle }) } @@ -102,6 +107,26 @@ impl GoonfiMarket { } } +/// Proves that `address` really is the market whose bytes sit in the account it was fetched with. +/// +/// A market does not record its own address, but its vaults do: a GoonFi vault is a token account +/// whose authority is the market. Reading the vault the market itself names and comparing that +/// authority is what ties a caller-supplied address to the data - the same shape of cross-check the +/// pump graduation builder makes through its curve vault's mint. It catches the realistic mistake, +/// an address from one market paired with another market's bytes; it is not proof against a caller +/// who deliberately assembles three mutually inconsistent accounts. +pub fn verify_market_address(address: Pubkey, base_vault_account: &Account) -> SurfpoolResult<()> { + let vault = TokenAccount::unpack(&base_vault_account.data) + .map_err(|error| invalid(format!("market base vault is not a token account: {error}")))?; + if vault.owner() != address { + return Err(invalid(format!( + "market base vault is held by {}, not by the targeted market {address}", + vault.owner() + ))); + } + Ok(()) +} + /// Rejects an account that is not a GoonFi market. /// /// The shared raw-layout guard has no owner predicate, so a foreign account of the same size @@ -303,18 +328,52 @@ mod tests { } } + /// A market's base vault: a token account whose authority is the market itself. + fn vault_account(authority: &Pubkey) -> Account { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(Pubkey::new_unique().as_ref()); + data[32..64].copy_from_slice(authority.as_ref()); + data[108] = 1; + Account { + data, + owner: spl_token_interface::ID, + ..Account::default() + } + } + const FIXTURE_ORACLE: Pubkey = Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); fn market() -> GoonfiMarket { + let address = Pubkey::new_unique(); GoonfiMarket::validate( - Pubkey::new_unique(), + address, &market_account(&FIXTURE_ORACLE), + &vault_account(&address), &oracle_account(), ) .expect("valid GoonFi market") } + /// The market account does not carry its own address, so a caller could hand `validate` one + /// market's address with another market's bytes. The base vault's authority is what catches it. + #[test] + fn rejects_an_address_that_does_not_own_the_market_vault() { + let other_market = Pubkey::new_unique(); + assert!( + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&FIXTURE_ORACLE), + &vault_account(&other_market), + &oracle_account() + ) + .is_err() + ); + + // A vault that is not a token account at all is refused before the comparison. + assert!(verify_market_address(Pubkey::new_unique(), &oracle_account()).is_err()); + } + #[test] fn builds_price_scenario_across_both_accounts() { let market = market(); @@ -421,8 +480,15 @@ mod tests { owner: Pubkey::new_unique(), ..market_account(&oracle) }; + let address = Pubkey::new_unique(); assert!( - GoonfiMarket::validate(Pubkey::new_unique(), &wrong_owner, &oracle_account()).is_err() + GoonfiMarket::validate( + address, + &wrong_owner, + &vault_account(&address), + &oracle_account() + ) + .is_err() ); // The raw guard cannot see the owner, which is the whole reason this check sits on top. assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); @@ -430,12 +496,24 @@ mod tests { let mut bad_magic = market_account(&oracle); bad_magic.data[0] ^= 0xff; assert!( - GoonfiMarket::validate(Pubkey::new_unique(), &bad_magic, &oracle_account()).is_err() + GoonfiMarket::validate( + address, + &bad_magic, + &vault_account(&address), + &oracle_account() + ) + .is_err() ); let no_pointer = market_account(&Pubkey::default()); assert!( - GoonfiMarket::validate(Pubkey::new_unique(), &no_pointer, &oracle_account()).is_err() + GoonfiMarket::validate( + address, + &no_pointer, + &vault_account(&address), + &oracle_account() + ) + .is_err() ); // The oracle carries no magic at all, so the owner check is its only discriminator. @@ -445,8 +523,9 @@ mod tests { }; assert!( GoonfiMarket::validate( - Pubkey::new_unique(), + address, &market_account(&oracle), + &vault_account(&address), &foreign_oracle ) .is_err() diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs index 9bbaaedc0..696389fde 100644 --- a/crates/core/src/tests/goonfi/mod.rs +++ b/crates/core/src/tests/goonfi/mod.rs @@ -23,7 +23,7 @@ use crate::{ scenarios::{ TemplateRegistry, protocols::goonfi::v1::{ - GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, + GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, vault_addresses, }, }, surfnet::svm::SurfnetSvm, @@ -689,8 +689,8 @@ async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { let market_key = Pubkey::from_str_const(fork.spec.market); let oracle_key = Pubkey::from_str_const(fork.spec.oracle); - let market = - GoonfiMarket::validate(market_key, &fork.market, &fork.oracle).expect("validate market"); + let market = GoonfiMarket::validate(market_key, &fork.market, &fork.base_vault, &fork.oracle) + .expect("validate market"); let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); let target = live_bid * 3 / 2; let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); @@ -1092,11 +1092,26 @@ async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { .flat_map(|market| [market.address, market.oracle]) .collect(); let accounts = live::fetch(&addresses).await; - for (discovered, accounts) in chunk.iter().zip(accounts.chunks_exact(2)) { - let validated = GoonfiMarket::validate(discovered.address, &accounts[0], &accounts[1]) - .expect("discovered market and oracle must retain their live owners and layouts"); + // The base vault is what proves each discovered address against the bytes at it, so the + // live check has to read it too. + let vaults: Vec = accounts + .chunks_exact(2) + .map(|pair| vault_addresses(&pair[0]).expect("a live market names its vaults")[0]) + .collect(); + let vault_accounts = live::fetch(&vaults).await; + for ((discovered, accounts), vault) in chunk + .iter() + .zip(accounts.chunks_exact(2)) + .zip(vault_accounts.iter()) + { + let validated = + GoonfiMarket::validate(discovered.address, &accounts[0], vault, &accounts[1]) + .expect( + "discovered market and oracle must retain their live owners and layouts", + ); assert_eq!( - validated.oracle, discovered.oracle, + validated.oracle(), + discovered.oracle, "live market oracle pointer changed" ); assert_eq!(&accounts[0].data[80..112], discovered.base_mint.as_ref()); diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 6614db201..09ba15790 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -468,15 +468,26 @@ impl Surfpool { .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; let oracle_address = GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; - let oracle_account = self - .fetch_surfnet_accounts(surfnet_port, &[oracle_address]) - .await? - .into_iter() - .next() - .flatten() + // The base vault rides along in the same read: it is what proves `market_address` belongs + // to the bytes just fetched, since a market does not record its own address. + let [base_vault_address, _] = + vault_addresses(&market_account).map_err(|error| error.to_string())?; + let referenced = self + .fetch_surfnet_accounts(surfnet_port, &[oracle_address, base_vault_address]) + .await?; + let oracle_account = referenced[0] + .as_ref() .ok_or_else(|| format!("GoonFi oracle {oracle_address} was not found"))?; - GoonfiMarket::validate(market_address, &market_account, &oracle_account) - .map_err(|error| error.to_string()) + let base_vault_account = referenced[1] + .as_ref() + .ok_or_else(|| format!("GoonFi base vault {base_vault_address} was not found"))?; + GoonfiMarket::validate( + market_address, + &market_account, + base_vault_account, + oracle_account, + ) + .map_err(|error| error.to_string()) } async fn stage_scenario(&self, scenario: Scenario) -> Result { From f4c824f234eb822cb95cc0bcea26d39ea1f4674a Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 11 Sep 2026 10:58:58 +0300 Subject: [PATCH 09/20] fix(goonfi): tie both vaults to the market, not just the base one The balance is read from the account passed in but written to the vault address decoded from the market, so a vault of the right mint belonging to another market would scale the wrong balance into this one - a requested drain could top the vault up instead. The authority check now lives inside the balance read, where it covers the quote side too, and the base-only call it replaces is gone. --- .../protocols/goonfi/v1/liquidity.rs | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index 07d131024..19828e096 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -21,7 +21,6 @@ use crate::{ use super::{ GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, - verify_market_address, }; /// Read, never written, so no template declares them. @@ -92,14 +91,13 @@ pub fn build_goonfi_liquidity_scenario( } let [base_vault, quote_vault] = vault_addresses(market_account)?; - verify_market_address(market, base_vault_account)?; let oracle = GoonfiMarket::oracle_address(market_account)?; validate_goonfi_oracle_layout(oracle_account)?; let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; - let base_amount = vault_amount(base_vault_account, "base", &base_mint)?; - let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint)?; + let base_amount = vault_amount(base_vault_account, "base", &base_mint, market)?; + let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint, market)?; let label = market_label(&base_mint, "e_mint); let registry = TemplateRegistry::new(); @@ -168,13 +166,20 @@ pub fn build_goonfi_liquidity_scenario( }) } -/// Reads a vault balance, proving first that the account really is that market's token vault. +/// Reads a vault balance, proving first that the account really is this market's vault for `side`. /// -/// An owner-and-length check is not enough: a mint is also owned by the token program and is long -/// enough to read an amount out of, so it would pass and its bytes would be misread as a balance. -/// Unpacking rejects anything that is not a token account, and the mint comparison ties the vault -/// to the side of the market it is supposed to hold. -fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> SurfpoolResult { +/// Three things have to line up, because the balance is read from the passed account but written to +/// the vault address decoded from the market. An owner-and-length check is not enough: a mint is +/// also token-program-owned and long enough to read an amount out of. Unpacking rejects anything +/// that is not a token account, the authority ties the vault to this market, and the mint ties it to +/// the right side of it - without all three, another market's vault of the same mint would pass and +/// its balance would be scaled into this market's vault. +fn vault_amount( + account: &Account, + side: &str, + expected_mint: &Pubkey, + market: Pubkey, +) -> SurfpoolResult { if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { return Err(invalid(format!( "{side} vault is not owned by a supported token program" @@ -182,6 +187,12 @@ fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> Surfpo } let vault = TokenAccount::unpack(&account.data) .map_err(|error| invalid(format!("{side} vault is not a token account: {error}")))?; + if vault.owner() != market { + return Err(invalid(format!( + "{side} vault is held by {}, not by the market {market} this scenario targets", + vault.owner() + ))); + } if vault.mint() != *expected_mint { return Err(invalid(format!( "{side} vault holds mint {} but the market's {side} mint is {expected_mint}", @@ -457,6 +468,33 @@ mod tests { ); } + /// The balance is read from the passed account but written to the vault the market names, so a + /// vault of the right mint belonging to another market would scale the wrong balance into this + /// one - a drain that silently tops the vault up instead. + #[test] + fn rejects_a_vault_belonging_to_another_market() { + let other_market = Pubkey::new_unique(); + let foreign_quote = { + let mut account = vault(&USDC, 999_999_999); + account.data[32..64].copy_from_slice(other_market.as_ref()); + account + }; + let error = build_goonfi_liquidity_scenario( + MARKET, + &market_account(&Pubkey::new_unique(), &Pubkey::new_unique()), + &vault(&WSOL, 1_000), + &foreign_quote, + &oracle(), + 0, + 0, + ) + .expect_err("a vault held by another market must be refused"); + assert!( + error.to_string().contains("quote vault is held by"), + "unexpected error: {error}" + ); + } + /// The market account carries no self-address, so the vault's authority is what ties the /// requested market to these bytes. #[test] From 0bc5e394b4bf382c60249db62bd83f22f2662682 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 11 Sep 2026 12:03:43 +0300 Subject: [PATCH 10/20] fix(goonfi): bind account inputs to market pointers Validate vault and oracle addresses against the market's pointers before reading balances or constructing overrides. Preserve the existing RPC reads and generic scenario templates. Add mismatch regressions and update MCP, discovery, and live-test callers. --- .../src/scenarios/protocols/goonfi/README.md | 7 + .../protocols/goonfi/v1/liquidity.rs | 201 +++++++++++------- .../scenarios/protocols/goonfi/v1/markets.rs | 12 +- .../src/scenarios/protocols/goonfi/v1/mod.rs | 6 +- .../scenarios/protocols/goonfi/v1/price.rs | 123 ++++++++--- crates/core/src/scenarios/registry.rs | 7 +- crates/core/src/tests/goonfi/mod.rs | 31 ++- crates/mcp/src/surfpool/mod.rs | 12 +- 8 files changed, 268 insertions(+), 131 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md index 74a7a134b..16412f6b5 100644 --- a/crates/core/src/scenarios/protocols/goonfi/README.md +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -68,6 +68,13 @@ These tools accept optional `surfnet_port`, defaulting to 8899, and read through Surfnet RPC. Missing accounts fall back to that Surfnet's datasource. The price tool stages through the shared Studio scenario API; Play registers the scenario. +Direct library callers pass each vault and oracle as `(Pubkey, &Account)`, preserving +the address used to read its data. Builders compare these addresses with the market's +pointers before using any balance or preparing overrides. Owner, token mint and vault +authority checks validate the account graph; they do not authenticate arbitrary bytes +that a caller deliberately labels with an unrelated address. RPC reads remain outside +the pure builders, as in Pump's graduation preparation. + Studio's PMM fair-value dialog selects a protocol, a live market and a human price. It calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnet_port`, matching the Tessera dialog convention. Consequently, these Studio GoonFi calls use the diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index 19828e096..853b7e5c9 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -13,16 +13,15 @@ use solana_account::Account; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenario}; +use super::{ + GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, +}; use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, types::TokenAccount, }; -use super::{ - GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, -}; - /// Read, never written, so no template declares them. const BASE_MINT_OFFSET: usize = 80; const QUOTE_MINT_OFFSET: usize = 112; @@ -64,15 +63,14 @@ pub fn vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> /// Scales each vault balance to the requested basis points and keeps the quote fresh. /// -/// `market_account` is the source of truth for the vault and oracle addresses; the three passed -/// accounts are the base vault, quote vault and oracle the caller fetched by those addresses, in -/// that order. A side left at 10000 bps is untouched and gets no override. +/// `market_account` is the source of truth for the vault and oracle addresses. Callers must keep +/// each account paired with the address used to read it. A side left at 10000 bps is untouched. pub fn build_goonfi_liquidity_scenario( market: Pubkey, market_account: &Account, - base_vault_account: &Account, - quote_vault_account: &Account, - oracle_account: &Account, + base_vault_account: (Pubkey, &Account), + quote_vault_account: (Pubkey, &Account), + oracle_account: (Pubkey, &Account), base_remaining_bps: u16, quote_remaining_bps: u16, ) -> SurfpoolResult { @@ -92,12 +90,24 @@ pub fn build_goonfi_liquidity_scenario( let [base_vault, quote_vault] = vault_addresses(market_account)?; let oracle = GoonfiMarket::oracle_address(market_account)?; - validate_goonfi_oracle_layout(oracle_account)?; + if oracle_account.0 != oracle { + return Err(invalid(format!( + "oracle address {} does not match the market's oracle {oracle}", + oracle_account.0 + ))); + } + validate_goonfi_oracle_layout(oracle_account.1)?; let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; - let base_amount = vault_amount(base_vault_account, "base", &base_mint, market)?; - let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint, market)?; + let base_amount = vault_amount(base_vault_account, base_vault, "base", &base_mint, market)?; + let quote_amount = vault_amount( + quote_vault_account, + quote_vault, + "quote", + "e_mint, + market, + )?; let label = market_label(&base_mint, "e_mint); let registry = TemplateRegistry::new(); @@ -166,20 +176,18 @@ pub fn build_goonfi_liquidity_scenario( }) } -/// Reads a vault balance, proving first that the account really is this market's vault for `side`. -/// -/// Three things have to line up, because the balance is read from the passed account but written to -/// the vault address decoded from the market. An owner-and-length check is not enough: a mint is -/// also token-program-owned and long enough to read an amount out of. Unpacking rejects anything -/// that is not a token account, the authority ties the vault to this market, and the mint ties it to -/// the right side of it - without all three, another market's vault of the same mint would pass and -/// its balance would be scaled into this market's vault. fn vault_amount( - account: &Account, + (address, account): (Pubkey, &Account), + expected_address: Pubkey, side: &str, expected_mint: &Pubkey, market: Pubkey, ) -> SurfpoolResult { + if address != expected_address { + return Err(invalid(format!( + "{side} vault address {address} does not match the market's {side} vault {expected_address}" + ))); + } if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { return Err(invalid(format!( "{side} vault is not owned by a supported token program" @@ -229,6 +237,9 @@ mod tests { use super::*; use crate::scenarios::protocols::goonfi::v1::{GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID}; + const FIXTURE_BASE_VAULT: Pubkey = Pubkey::new_from_array([2; 32]); + const FIXTURE_QUOTE_VAULT: Pubkey = Pubkey::new_from_array([3; 32]); + const FIXTURE_ORACLE: Pubkey = Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); @@ -276,17 +287,57 @@ mod tests { } } + #[test] + fn rejects_unrelated_vault_with_matching_mint_and_authority() { + let market = market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT); + for (base_address, quote_address, side) in [ + (Pubkey::new_unique(), FIXTURE_QUOTE_VAULT, "base"), + (FIXTURE_BASE_VAULT, Pubkey::new_unique(), "quote"), + ] { + let error = build_goonfi_liquidity_scenario( + MARKET, + &market, + (base_address, &vault(&WSOL, 999_999_999)), + (quote_address, &vault(&USDC, 999_999_999)), + (FIXTURE_ORACLE, &oracle()), + 5_000, + 5_000, + ) + .expect_err("an unrelated account's balance must not be scaled into the real vault"); + assert!( + error.to_string().contains(&format!("{side} vault address")), + "{error}" + ); + } + } + + #[test] + fn rejects_an_unrelated_publisher_owned_oracle() { + let market = market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT); + let error = build_goonfi_liquidity_scenario( + MARKET, + &market, + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1_000)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1_000)), + (Pubkey::new_unique(), &oracle()), + 5_000, + FULL_BPS, + ) + .expect_err("freshness must target the oracle whose account was validated"); + assert!(error.to_string().contains("oracle address"), "{error}"); + } + #[test] fn drains_both_vaults_and_keeps_the_quote_fresh() { - let base_vault = Pubkey::new_unique(); - let quote_vault = Pubkey::new_unique(); + let base_vault = FIXTURE_BASE_VAULT; + let quote_vault = FIXTURE_QUOTE_VAULT; let market = MARKET; let preparation = build_goonfi_liquidity_scenario( market, &market_account(&base_vault, "e_vault), - &vault(&WSOL, 2_441_078_070_812), - &vault(&USDC, 216_136_231_615), - &oracle(), + (FIXTURE_BASE_VAULT, &vault(&WSOL, 2_441_078_070_812)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 216_136_231_615)), + (FIXTURE_ORACLE, &oracle()), 0, 0, ) @@ -321,14 +372,14 @@ mod tests { #[test] fn scales_partially_and_skips_an_unchanged_side() { - let base_vault = Pubkey::new_unique(); - let quote_vault = Pubkey::new_unique(); + let base_vault = FIXTURE_BASE_VAULT; + let quote_vault = FIXTURE_QUOTE_VAULT; let preparation = build_goonfi_liquidity_scenario( MARKET, &market_account(&base_vault, "e_vault), - &vault(&WSOL, 1_000), - &vault(&USDC, 999), - &oracle(), + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1_000)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 999)), + (FIXTURE_ORACLE, &oracle()), 2_500, FULL_BPS, ) @@ -345,8 +396,8 @@ mod tests { #[test] fn rejects_bad_basis_points_and_accounts() { - let base_vault = Pubkey::new_unique(); - let quote_vault = Pubkey::new_unique(); + let base_vault = FIXTURE_BASE_VAULT; + let quote_vault = FIXTURE_QUOTE_VAULT; let good_market = market_account(&base_vault, "e_vault); // Out of range and a no-op leave nothing to prepare. @@ -354,11 +405,11 @@ mod tests { build_goonfi_liquidity_scenario( MARKET, &good_market, - &vault(&WSOL, 1), - &vault(&USDC, 1), - &oracle(), + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &oracle()), 10_001, - 0 + 0, ) .is_err() ); @@ -366,11 +417,11 @@ mod tests { build_goonfi_liquidity_scenario( MARKET, &good_market, - &vault(&WSOL, 1), - &vault(&USDC, 1), - &oracle(), + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &oracle()), + FULL_BPS, FULL_BPS, - FULL_BPS ) .is_err() ); @@ -384,11 +435,11 @@ mod tests { build_goonfi_liquidity_scenario( MARKET, &foreign_market, - &vault(&WSOL, 1), - &vault(&USDC, 1), - &oracle(), + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &oracle()), + 0, 0, - 0 ) .is_err() ); @@ -402,11 +453,11 @@ mod tests { build_goonfi_liquidity_scenario( MARKET, &good_market, - &foreign_vault, - &vault(&USDC, 1), - &oracle(), + (FIXTURE_BASE_VAULT, &foreign_vault), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &oracle()), + 0, 0, - 0 ) .is_err() ); @@ -420,11 +471,11 @@ mod tests { build_goonfi_liquidity_scenario( MARKET, &good_market, - &vault(&WSOL, 1), - &vault(&USDC, 1), - &foreign_oracle, + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &foreign_oracle), + 0, 0, - 0 ) .is_err() ); @@ -434,7 +485,7 @@ mod tests { /// misread an amount out of. Unpacking plus the mint comparison is what rejects it. #[test] fn rejects_a_vault_that_is_not_this_markets_token_account() { - let market = market_account(&Pubkey::new_unique(), &Pubkey::new_unique()); + let market = market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT); let mint_account = Account { data: vec![0u8; 82], owner: spl_token_interface::ID, @@ -444,11 +495,11 @@ mod tests { build_goonfi_liquidity_scenario( MARKET, &market, - &mint_account, - &vault(&USDC, 1), - &oracle(), + (FIXTURE_BASE_VAULT, &mint_account), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &oracle()), + 0, 0, - 0 ) .is_err() ); @@ -458,11 +509,11 @@ mod tests { build_goonfi_liquidity_scenario( MARKET, &market, - &vault(&USDC, 1), - &vault(&USDC, 1), - &oracle(), + (FIXTURE_BASE_VAULT, &vault(&USDC, 1)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &oracle()), + 0, 0, - 0 ) .is_err() ); @@ -481,10 +532,10 @@ mod tests { }; let error = build_goonfi_liquidity_scenario( MARKET, - &market_account(&Pubkey::new_unique(), &Pubkey::new_unique()), - &vault(&WSOL, 1_000), - &foreign_quote, - &oracle(), + &market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT), + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1_000)), + (FIXTURE_QUOTE_VAULT, &foreign_quote), + (FIXTURE_ORACLE, &oracle()), 0, 0, ) @@ -499,17 +550,17 @@ mod tests { /// requested market to these bytes. #[test] fn rejects_a_market_address_that_does_not_hold_the_vault() { - let base_vault = Pubkey::new_unique(); - let quote_vault = Pubkey::new_unique(); + let base_vault = FIXTURE_BASE_VAULT; + let quote_vault = FIXTURE_QUOTE_VAULT; assert!( build_goonfi_liquidity_scenario( Pubkey::new_unique(), &market_account(&base_vault, "e_vault), - &vault(&WSOL, 1), - &vault(&USDC, 1), - &oracle(), + (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, &oracle()), + 0, 0, - 0 ) .is_err() ); @@ -517,8 +568,8 @@ mod tests { #[test] fn resolves_vault_addresses_from_the_market() { - let base_vault = Pubkey::new_unique(); - let quote_vault = Pubkey::new_unique(); + let base_vault = FIXTURE_BASE_VAULT; + let quote_vault = FIXTURE_QUOTE_VAULT; let [base, quote] = vault_addresses(&market_account(&base_vault, "e_vault)).unwrap(); assert_eq!(base, base_vault); assert_eq!(quote, quote_vault); diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index 0a73ee217..958c518f7 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -10,6 +10,7 @@ use solana_commitment_config::CommitmentConfig; use solana_pubkey::Pubkey; use surfpool_types::VERIFIED_TOKENS_BY_SYMBOL; +use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket, vault_addresses}; use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, @@ -17,8 +18,6 @@ use crate::{ types::MintAccount, }; -use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket, vault_addresses}; - #[derive(Debug, PartialEq)] pub struct GoonfiDiscoveredMarket { pub address: Pubkey, @@ -52,7 +51,7 @@ pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { } /// The accounts a market points at: its two mints, its oracle, and its base vault. The vault comes -/// along because it is what proves the market's address matches these bytes. +/// along to check that its token authority is the market. fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 4]> { let oracle = GoonfiMarket::oracle_address(account)?; let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); @@ -86,7 +85,12 @@ fn resolve_market( SurfpoolError::internal(format!("GoonFi referenced account {address} was not found")) }) }; - GoonfiMarket::validate(address, account, required(&base_vault)?, required(&oracle)?)?; + GoonfiMarket::validate( + address, + account, + (base_vault, required(&base_vault)?), + (oracle, required(&oracle)?), + )?; Ok(GoonfiDiscoveredMarket { address, oracle, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index db9eac150..ef84741c3 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -3,11 +3,9 @@ mod markets; mod price; pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses}; - +pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; pub use price::{ GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, - validate_goonfi_oracle_layout, verify_market_address, + validate_goonfi_oracle_layout, }; - -pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 651b512c6..d033c412f 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -12,6 +12,7 @@ use solana_account::Account; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayout, Scenario}; +use super::vault_addresses; use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, @@ -83,17 +84,30 @@ impl GoonfiMarket { Ok(oracle) } - /// `base_vault_account` must be the account at the base vault the market itself names; it is - /// what proves `address` belongs to `market_account` (see `verify_market_address`). + /// Account addresses must remain paired with the data returned by the account reader. + /// These checks validate the account graph; they do not authenticate caller-supplied bytes. pub fn validate( address: Pubkey, market_account: &Account, - base_vault_account: &Account, - oracle_account: &Account, + base_vault_account: (Pubkey, &Account), + oracle_account: (Pubkey, &Account), ) -> SurfpoolResult { let oracle = Self::oracle_address(market_account)?; - verify_market_address(address, base_vault_account)?; - validate_goonfi_oracle_layout(oracle_account)?; + let [base_vault, _] = vault_addresses(market_account)?; + if base_vault_account.0 != base_vault { + return Err(invalid(format!( + "base vault address {} does not match the market's base vault {base_vault}", + base_vault_account.0 + ))); + } + if oracle_account.0 != oracle { + return Err(invalid(format!( + "oracle address {} does not match the market's oracle {oracle}", + oracle_account.0 + ))); + } + validate_market_authority(address, base_vault_account.1)?; + validate_goonfi_oracle_layout(oracle_account.1)?; Ok(Self { address, oracle }) } @@ -107,15 +121,14 @@ impl GoonfiMarket { } } -/// Proves that `address` really is the market whose bytes sit in the account it was fetched with. -/// -/// A market does not record its own address, but its vaults do: a GoonFi vault is a token account -/// whose authority is the market. Reading the vault the market itself names and comparing that -/// authority is what ties a caller-supplied address to the data - the same shape of cross-check the -/// pump graduation builder makes through its curve vault's mint. It catches the realistic mistake, -/// an address from one market paired with another market's bytes; it is not proof against a caller -/// who deliberately assembles three mutually inconsistent accounts. -pub fn verify_market_address(address: Pubkey, base_vault_account: &Account) -> SurfpoolResult<()> { +fn validate_market_authority(address: Pubkey, base_vault_account: &Account) -> SurfpoolResult<()> { + if base_vault_account.owner != spl_token_interface::ID + && base_vault_account.owner != spl_token_2022_interface::ID + { + return Err(invalid( + "base vault is not owned by a supported token program", + )); + } let vault = TokenAccount::unpack(&base_vault_account.data) .map_err(|error| invalid(format!("market base vault is not a token account: {error}")))?; if vault.owner() != address { @@ -308,10 +321,15 @@ fn invalid(message: impl Into) -> SurfpoolError { mod tests { use super::*; + const FIXTURE_BASE_VAULT: Pubkey = Pubkey::new_from_array([2; 32]); + const FIXTURE_QUOTE_VAULT: Pubkey = Pubkey::new_from_array([3; 32]); + fn market_account(oracle: &Pubkey) -> Account { let mut data = vec![0; MARKET_LAYOUT.account_size]; let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[144..176].copy_from_slice(FIXTURE_BASE_VAULT.as_ref()); + data[176..208].copy_from_slice(FIXTURE_QUOTE_VAULT.as_ref()); data[ORACLE_POINTER_OFFSET..ORACLE_POINTER_OFFSET + 32].copy_from_slice(oracle.as_ref()); Account { data, @@ -349,12 +367,61 @@ mod tests { GoonfiMarket::validate( address, &market_account(&FIXTURE_ORACLE), - &vault_account(&address), - &oracle_account(), + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (FIXTURE_ORACLE, &oracle_account()), ) .expect("valid GoonFi market") } + #[test] + fn rejects_unrelated_base_vault_with_target_market_authority() { + let target_market = Pubkey::new_unique(); + let mut other_market = market_account(&FIXTURE_ORACLE); + other_market.data[144..176].copy_from_slice(Pubkey::new_unique().as_ref()); + other_market.data[176..208].copy_from_slice(Pubkey::new_unique().as_ref()); + let error = GoonfiMarket::validate( + target_market, + &other_market, + (Pubkey::new_unique(), &vault_account(&target_market)), + (FIXTURE_ORACLE, &oracle_account()), + ) + .expect_err("an unrelated vault must not validate another market's account graph"); + assert!(error.to_string().contains("base vault address"), "{error}"); + } + + #[test] + fn rejects_an_unrelated_publisher_owned_oracle() { + let address = Pubkey::new_unique(); + let error = GoonfiMarket::validate( + address, + &market_account(&FIXTURE_ORACLE), + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (Pubkey::new_unique(), &oracle_account()), + ) + .expect_err("a correctly shaped oracle at another address must be refused"); + assert!(error.to_string().contains("oracle address"), "{error}"); + } + + #[test] + fn rejects_a_base_vault_owned_by_an_unrelated_program() { + let address = Pubkey::new_unique(); + let foreign_vault = Account { + owner: Pubkey::new_unique(), + ..vault_account(&address) + }; + let error = GoonfiMarket::validate( + address, + &market_account(&FIXTURE_ORACLE), + (FIXTURE_BASE_VAULT, &foreign_vault), + (FIXTURE_ORACLE, &oracle_account()), + ) + .expect_err("matching token bytes must not bypass the token program owner check"); + assert!( + error.to_string().contains("supported token program"), + "{error}" + ); + } + /// The market account does not carry its own address, so a caller could hand `validate` one /// market's address with another market's bytes. The base vault's authority is what catches it. #[test] @@ -364,14 +431,14 @@ mod tests { GoonfiMarket::validate( Pubkey::new_unique(), &market_account(&FIXTURE_ORACLE), - &vault_account(&other_market), - &oracle_account() + (FIXTURE_BASE_VAULT, &vault_account(&other_market)), + (FIXTURE_ORACLE, &oracle_account()), ) .is_err() ); // A vault that is not a token account at all is refused before the comparison. - assert!(verify_market_address(Pubkey::new_unique(), &oracle_account()).is_err()); + assert!(validate_market_authority(Pubkey::new_unique(), &oracle_account()).is_err()); } #[test] @@ -485,8 +552,8 @@ mod tests { GoonfiMarket::validate( address, &wrong_owner, - &vault_account(&address), - &oracle_account() + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (oracle, &oracle_account()), ) .is_err() ); @@ -499,8 +566,8 @@ mod tests { GoonfiMarket::validate( address, &bad_magic, - &vault_account(&address), - &oracle_account() + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (oracle, &oracle_account()), ) .is_err() ); @@ -510,8 +577,8 @@ mod tests { GoonfiMarket::validate( address, &no_pointer, - &vault_account(&address), - &oracle_account() + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (oracle, &oracle_account()), ) .is_err() ); @@ -525,8 +592,8 @@ mod tests { GoonfiMarket::validate( address, &market_account(&oracle), - &vault_account(&address), - &foreign_oracle + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (oracle, &foreign_oracle), ) .is_err() ); diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 6048f06d9..4824e1368 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -299,9 +299,12 @@ impl TemplateRegistry { #[cfg(test)] mod tests { - use anchor_lang_idl::types::IdlType; - use std::{collections::BTreeSet, collections::HashMap, str::FromStr}; + use std::{ + collections::{BTreeSet, HashMap}, + str::FromStr, + }; + use anchor_lang_idl::types::IdlType; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, PdaSeed}; diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs index 696389fde..9b067c16e 100644 --- a/crates/core/src/tests/goonfi/mod.rs +++ b/crates/core/src/tests/goonfi/mod.rs @@ -689,8 +689,16 @@ async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { let market_key = Pubkey::from_str_const(fork.spec.market); let oracle_key = Pubkey::from_str_const(fork.spec.oracle); - let market = GoonfiMarket::validate(market_key, &fork.market, &fork.base_vault, &fork.oracle) - .expect("validate market"); + let market = GoonfiMarket::validate( + market_key, + &fork.market, + ( + Pubkey::from_str_const(fork.spec.base_vault), + &fork.base_vault, + ), + (oracle_key, &fork.oracle), + ) + .expect("validate market"); let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); let target = live_bid * 3 / 2; let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); @@ -1092,23 +1100,24 @@ async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { .flat_map(|market| [market.address, market.oracle]) .collect(); let accounts = live::fetch(&addresses).await; - // The base vault is what proves each discovered address against the bytes at it, so the - // live check has to read it too. + // Include each base vault to validate its token authority against the discovered market. let vaults: Vec = accounts .chunks_exact(2) .map(|pair| vault_addresses(&pair[0]).expect("a live market names its vaults")[0]) .collect(); let vault_accounts = live::fetch(&vaults).await; - for ((discovered, accounts), vault) in chunk + for ((discovered, accounts), (vault_address, vault)) in chunk .iter() .zip(accounts.chunks_exact(2)) - .zip(vault_accounts.iter()) + .zip(vaults.iter().zip(vault_accounts.iter())) { - let validated = - GoonfiMarket::validate(discovered.address, &accounts[0], vault, &accounts[1]) - .expect( - "discovered market and oracle must retain their live owners and layouts", - ); + let validated = GoonfiMarket::validate( + discovered.address, + &accounts[0], + (*vault_address, vault), + (discovered.oracle, &accounts[1]), + ) + .expect("discovered market and oracle must retain their live owners and layouts"); assert_eq!( validated.oracle(), discovered.oracle, diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 09ba15790..d209a7e25 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -468,8 +468,6 @@ impl Surfpool { .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; let oracle_address = GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; - // The base vault rides along in the same read: it is what proves `market_address` belongs - // to the bytes just fetched, since a market does not record its own address. let [base_vault_address, _] = vault_addresses(&market_account).map_err(|error| error.to_string())?; let referenced = self @@ -484,8 +482,8 @@ impl Surfpool { GoonfiMarket::validate( market_address, &market_account, - base_vault_account, - oracle_account, + (base_vault_address, base_vault_account), + (oracle_address, oracle_account), ) .map_err(|error| error.to_string()) } @@ -1214,9 +1212,9 @@ impl Surfpool { let preparation = match build_goonfi_liquidity_scenario( market_address, &market_account, - base_account, - quote_account, - oracle_account, + (base_vault, base_account), + (quote_vault, quote_account), + (oracle, oracle_account), params.base_remaining_bps.unwrap_or(0), params.quote_remaining_bps.unwrap_or(0), ) { From 283a7cdaa7db1bef226ce46b2596dabdd72f1fb4 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Tue, 15 Sep 2026 07:57:59 +0300 Subject: [PATCH 11/20] fix(mcp): take camelCase tool arguments like the pump tool The Tessera, HumidiFi and GoonFi parameter structs deserialized their fields as snake_case, so a client sending surfnetPort the way the pump, get_template and search_constant_options tools expect it was ignored and the read fell back to port 8899. Scenario tools now share one convention. --- crates/core/src/scenarios/protocols/goonfi/README.md | 4 ++-- crates/mcp/src/surfpool/mod.rs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md index 16412f6b5..d917d246e 100644 --- a/crates/core/src/scenarios/protocols/goonfi/README.md +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -64,7 +64,7 @@ The backend exposes three GoonFi MCP tools: liquidity rather than a stale quote. Both default to 0; an omitted market selects the default SOL/USDC market. -These tools accept optional `surfnet_port`, defaulting to 8899, and read through the local +These tools accept optional `surfnetPort`, defaulting to 8899, with camelCase argument names like the pump tool, and read through the local Surfnet RPC. Missing accounts fall back to that Surfnet's datasource. The price tool stages through the shared Studio scenario API; Play registers the scenario. @@ -76,7 +76,7 @@ that a caller deliberately labels with an unrelated address. RPC reads remain ou the pure builders, as in Pump's graduation preparation. Studio's PMM fair-value dialog selects a protocol, a live market and a human price. It -calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnet_port`, +calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnetPort`, matching the Tessera dialog convention. Consequently, these Studio GoonFi calls use the backend's default RPC port. Studio retains only each catalog entry's market address and label; the backend resolves the oracle when creating a price scenario. diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index d209a7e25..4b232f005 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -44,12 +44,14 @@ mod set_token_account; mod start_surfnet; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct ListGoonfiMarketsParams { #[schemars(description = "Port of the selected local Surfnet RPC; default 8899.")] pub surfnet_port: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct CreateGoonfiPriceScenarioParams { #[schemars( description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." @@ -66,6 +68,7 @@ pub struct CreateGoonfiPriceScenarioParams { } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct CreateGoonfiLiquidityScenarioParams { #[schemars( description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." From 62c441e57bd69da8cf62c07d3173d14efcd8d0d9 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Tue, 15 Sep 2026 17:54:44 +0300 Subject: [PATCH 12/20] refactor(scenarios): address Kamino review feedback --- crates/core/src/scenarios/README.md | 17 ++++--- .../kamino-liquidation-arbitrage.json | 1 - .../src/scenarios/protocols/kamino/README.md | 17 ++++--- .../protocols/kamino/farms/v1/overrides.yaml | 8 +-- .../kamino/liquidity/v1/overrides.yaml | 8 +-- .../protocols/kamino/scope/v1/overrides.yaml | 5 +- .../protocols/kamino/swap/v1/overrides.yaml | 5 +- .../protocols/kamino/v1/overrides.yaml | 23 +++------ .../protocols/kamino/vault/v1/overrides.yaml | 5 +- crates/core/src/scenarios/registry.rs | 6 ++- crates/core/src/surfnet/svm.rs | 49 +++++++++++++------ 11 files changed, 70 insertions(+), 74 deletions(-) diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 70e080af2..0db4e9745 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -35,14 +35,15 @@ whole struct or array) also works, but it must be **complete** - every field of padding included - because the account is re-encoded with Borsh. An out-of-range index or a non-numeric segment on an array is a hard error, never a silent write elsewhere. -By default an override applies to exactly one slot. Set `"persist": true` and it is re-applied on -every following slot, which is needed when something else writes the account in between - a -transaction, or another override fetching it fresh. Persist inputs nothing in the scenario writes -(an oracle price, a disabled switch, a risk parameter), never state the transactions under test -mutate: re-applying reverts their writes at the start of the next slot, so a pool would refill -itself after every swap. Only one entry is queued per override, so it is never applied twice to -one slot, and `fetchBeforeUse` applies to the first slot only - once the account is forked, later -slots re-pin the fields without re-fetching it. +By default an override is applied once, and the resulting account state naturally remains in later +slots. Leave `"persist"` unset unless a known later transaction, account fetch, or override will +replace a scenario-controlled input that must stay pinned. With `"persist": true`, Surfpool +re-applies the values at the beginning of every following slot. It does not continuously protect +them from writes within a slot. Never pin state whose transaction-driven evolution the scenario is +measuring: re-applying a balance, position, or accumulator undoes those writes at the next slot, so +a pool could refill itself after every swap. Only one entry is queued per override, so it is never +applied twice to one slot, and `fetchBeforeUse` applies to the first slot only - later slots re-pin +the fields without re-fetching the account. ### Kamino integration tests diff --git a/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json b/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json index c511bdd4f..a565b5999 100644 --- a/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json +++ b/crates/core/src/scenarios/examples/kamino-liquidation-arbitrage.json @@ -11,7 +11,6 @@ "scenarioRelativeSlot": 0, "enabled": true, "fetchBeforeUse": true, - "persist": true, "account": { "pubkey": "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C" }, diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md index 7aee1b108..71678503e 100644 --- a/crates/core/src/scenarios/protocols/kamino/README.md +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -3,8 +3,8 @@ Surfpool bundles IDLs and override templates for **six Kamino programs**, so a scenario can put a Kamino market into whatever state you need before your code runs against it. -This is a how-to. For how scenarios work in general see the [scenarios README](../../README.md) -every field's own purpose and units are on the template itself, visible in Studio and via +This is a how-to. For how scenarios work in general see the [scenarios README](../../README.md). +Every field's own purpose and units are on the template itself, visible in Studio and via `get_override_templates`. ## Two rules that decide whether an override sticks @@ -19,10 +19,11 @@ So overriding a computed value is discarded moments later. | A price | `kamino-scope-price` | `liquidity.market_price_sf` | | Position health | `kamino-reserve-config` → `liquidation_threshold_pct` | `kamino-obligation-health` | -**2. Add `"persist": true`** only to inputs your scenario never writes - prices, risk config, -caps. Never to state your transactions mutate (reserve liquidity, obligation or vault balances): -re-applying reverts their writes each slot, so a swap leaves no trace and the arbitrage it measures -is not real. +**2. Do not persist by default.** An override applied once already remains in later slots. Add +`"persist": true` only when a known later update or fetch would overwrite a scenario-controlled +input that must stay pinned, such as an oracle price while its updater continues running. Never pin +state whose changes you are testing (reserve liquidity, obligations, farm rewards, or vault +balances): re-applying it undoes those changes at the beginning of the next slot. ## Number formats @@ -63,7 +64,7 @@ Two independent levers where either works, both together is safest. { "templateId": "kamino-scope-price", "scenarioRelativeSlot": 0, "enabled": true, - "fetchBeforeUse": true, "persist": true, + "fetchBeforeUse": true, "account": { "pubkey": "3NJYftD5sjVfxSnUdZ1wVML8f3aC6mp1CXCL6L7TnU8C" }, "values": { "prices.492.price.value": 2124828, "prices.492.price.exp": 8 } } @@ -201,7 +202,7 @@ kamino-swap-order | `exceeds what a JSON number can hold exactly` | Pass large `u128`/`i128` values as decimal strings, e.g. `"1152921504606846976000"`. Plain JSON numbers are fine below 2^53 | | `Account with discriminator ... not found in IDL` | The account is not Anchor-based (e.g. Raydium AMM v4). It cannot be overridden through the IDL path | | `Failed to resolve account address` | The `pubkey` is not valid base58 | -| Override reverted after a transaction touched the account | Add `"persist": true` - but only if that field is an input, not state the transaction is meant to change | +| A later update replaced a scenario-controlled input | Add `"persist": true` only when that input must stay pinned; do not use it merely because the scenario spans multiple slots | | A value the program recomputes will not stay put | Pin the input it reads instead: Scope price over a Reserve's cached price, `liquidation_threshold_pct` over the Obligation's health fields | --- diff --git a/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml index 17d89175e..51e5b3fa5 100644 --- a/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/farms/v1/overrides.yaml @@ -97,9 +97,6 @@ templates: EXAMPLE - "every staker is owed more": reward_infos.0.reward_per_share_scaled: 5000000 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-farms-user-rewards name: Override Farm User Rewards description: Override one user's farm stake and reward balances @@ -146,9 +143,6 @@ templates: rewards_issued_unclaimed.0: 500000000 last_claim_ts.0: 0 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-farms-farm-config name: Override Farm Configuration description: Override Kamino farm caps, lockups and cooldowns @@ -216,4 +210,4 @@ templates: description: >- The protocol's cut of all rewards in bps, taken before users receive anything. Example: 0 address: - type: pubkey \ No newline at end of file + type: pubkey diff --git a/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml index 856890193..d3a758c17 100644 --- a/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/liquidity/v1/overrides.yaml @@ -48,9 +48,6 @@ templates: EXAMPLE - "the strategy collected 1 SOL of fees": token_a_amounts: 1000000000 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-liquidity-strategy-rewards name: Override Liquidity Strategy Rewards description: Override Kamino Liquidity strategy reward balances @@ -107,9 +104,6 @@ templates: kamino_rewards.0.amount_uncollected: 5000000 kamino_rewards.0.amount_available: 1000000000 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-liquidity-strategy-guards name: Override Liquidity Strategy Guards description: Override Kamino Liquidity strategy caps and slippage guards @@ -228,4 +222,4 @@ templates: fees_fee: 0 reward0_fee: 0 reward1_fee: 0 - reward2_fee: 0 \ No newline at end of file + reward2_fee: 0 diff --git a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml index 9cb81179b..18ea4ad65 100644 --- a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml @@ -45,8 +45,9 @@ templates: [210, 3] means price = prices[210] * prices[3] 4. Set price.value = usd_price * 10^exp, keeping exp as you found it 5. Set last_updated_slot and unix_timestamp to now, or Kamino rejects the price as stale - 6. Set persist: true if the scenario runs past one slot, so a transaction that writes - this account cannot restore the real price. Safe here: nothing in a fork cranks Scope + 6. Leave persist unset unless a known later Scope update or full-account fetch would restore + the real price. If that happens and the simulated price must remain pinned, set persist: true; + Surfpool will restore it at the beginning of each following slot SCOPE INDICES (verified 2026-08-06, do not guess these): - 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH (Main Market): diff --git a/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml index 9e1e474bd..f531e2c82 100644 --- a/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/swap/v1/overrides.yaml @@ -65,9 +65,6 @@ templates: remaining_input_amount: 500000000 expected_output_amount: 100000000 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-swap-global-config name: Override Swap Global Config description: Override Kamino limit order global switches and fees @@ -111,4 +108,4 @@ templates: meteora-*) to build a profitable route EXAMPLE - "halt the order book": - emergency_mode: 1 \ No newline at end of file + emergency_mode: 1 diff --git a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml index 7ab89e5f4..c4abd78ed 100644 --- a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml @@ -48,9 +48,6 @@ templates: EXAMPLE - "reserve has run dry" (forces the withdrawal queue): liquidity.total_available_amount: 0 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-reserve-config name: Override Reserve Risk Configuration description: Override Kamino Reserve LTV, liquidation thresholds and bonuses @@ -337,9 +334,6 @@ templates: queued_collateral_amount: 500 invalid: 0 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - # ========================================== # Named reserves - addresses pre-filled # ========================================== @@ -388,8 +382,9 @@ templates: EXAMPLE - "liquidate SOL collateral above 50% LTV": config.liquidation_threshold_pct: 50 - persist: true is safe for the config.* fields only. liquidity.* and last_update.* are - rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve. + Leave persist unset unless a known later write or fetch would replace config.* and the + scenario must keep that configuration pinned. If persistence is needed, override config.* + fields only; liquidity.* and last_update.* are transaction-owned state. - id: kamino-reserve-main-usdc name: Override USDC Reserve (Main Market) description: Override the USDC reserve of Kamino's Main Market @@ -422,8 +417,9 @@ templates: EXAMPLE - "USDC depegs to $0.90": use kamino-scope-price with prices.13.price.value: 90000000 and prices.13.price.exp: 8 - persist: true is safe for the config.* fields only. liquidity.* and last_update.* are - rewritten by refresh_reserve, so pinning them fights every transaction that touches the reserve. + Leave persist unset unless a known later write or fetch would replace config.* and the + scenario must keep that configuration pinned. If persistence is needed, override config.* + fields only; liquidity.* and last_update.* are transaction-owned state. # ========================================== # Obligation # ========================================== @@ -466,9 +462,6 @@ templates: borrow_factor_adjusted_debt_value_sf: "1152921504606846976000" unhealthy_borrow_value_sf: "576460752303423488000" - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-obligation-positions name: Override Obligation Positions description: Override the deposits and borrows of a Kamino Obligation @@ -501,9 +494,6 @@ templates: deposits.0.deposited_amount: 10000000000 has_debt: 1 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-obligation-orders name: Override Obligation Orders description: Override Kamino Obligation stop-loss and take-profit orders @@ -600,4 +590,3 @@ templates: elevation_groups.1.ltv_pct: 90 elevation_groups.1.liquidation_threshold_pct: 95 elevation_groups.1.allow_new_loans: 1 - diff --git a/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml index f8d190d83..7ccb8871b 100644 --- a/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/vault/v1/overrides.yaml @@ -63,9 +63,6 @@ templates: EXAMPLE - "the vault earned 1000 USDC of yield" (6 decimals): token_available: 1000000000 - DO NOT set persist: true here - transactions write these fields, and re-applying the - override reverts their writes at the start of every following slot. - - id: kamino-vault-fees name: Override Earn Vault Fees description: Override Kamino Earn vault performance, management and exit fees @@ -221,4 +218,4 @@ templates: EXAMPLE - "this reserve is approved for both allocation and investment": reserve: D6q6wuQSrifJKZYpR1M8R4YawnLDtDsMmWM1NbBmgJ59 whitelist_add_allocation: 1 - whitelist_invest: 1 \ No newline at end of file + whitelist_invest: 1 diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 217ac8039..689ad70da 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -250,8 +250,10 @@ impl TemplateRegistry { #[cfg(test)] mod tests { - use anchor_lang_idl::types::IdlType; - use std::{collections::BTreeSet, collections::HashMap, str::FromStr}; + use std::{ + collections::{BTreeSet, HashMap}, + str::FromStr, + }; use solana_pubkey::Pubkey; use surfpool_types::{AccountAddress, PdaSeed}; diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index baff46c76..03e1ecf26 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -660,6 +660,8 @@ fn synthetic_blockhash_for_slot(slot: Slot, genesis_slot: Slot) -> SyntheticBloc /// What one `fetch_before_use` attempt settled. Decides whether a persisted override keeps /// asking on later slots, which it must while another attempt could still change the answer. enum FetchOutcome { + /// No further remote fetch is needed: none was requested, the account was already settled + /// earlier in this slot, or the requested fetch succeeded. Retired, /// There is no remote to ask. Only a local account can satisfy the request. NoRemote, @@ -669,6 +671,16 @@ enum FetchOutcome { Unanswered, } +impl FetchOutcome { + fn is_fetch_retired(&self, has_local_account: bool) -> bool { + match self { + Self::Retired => true, + Self::Unanswered => false, + Self::NoRemote | Self::NotOnRemote => has_local_account, + } + } +} + impl SurfnetSvm { pub(crate) fn mark_transaction_pending(&mut self, signature: Signature) { *self @@ -3012,18 +3024,14 @@ impl SurfnetSvm { }; // The request is only retired when another attempt could no longer change anything. - let fetch_retired = match fetch_outcome { - FetchOutcome::Retired => true, - FetchOutcome::Unanswered => false, - FetchOutcome::NoRemote | FetchOutcome::NotOnRemote => existing_account.is_some(), - }; + let is_fetch_retired = fetch_outcome.is_fetch_retired(existing_account.is_some()); if override_instance.persist { let mut requeued = override_instance.clone(); - if requeued.fetch_before_use && fetch_retired { + if requeued.fetch_before_use && is_fetch_retired { requeued.fetch_before_use = false; } - if let Err(e) = self.reschedule_override_for_next_slot(&requeued, target_slot) { + if let Err(e) = self.reschedule_override_for_next_slot(requeued, target_slot) { restore_unprocessed(self, index); return Err(e); } @@ -3182,7 +3190,7 @@ impl SurfnetSvm { /// already queued there. One entry per id, so an override cannot be applied twice to one slot. fn reschedule_override_for_next_slot( &mut self, - instance: &OverrideInstance, + instance: OverrideInstance, target_slot: Slot, ) -> SurfpoolResult<()> { let next_slot = target_slot.checked_add(1).ok_or_else(|| { @@ -3201,9 +3209,9 @@ impl SurfnetSvm { && queued.account == instance.account && queued.template_id == instance.template_id }) { - *existing = instance.clone(); + *existing = instance; } else { - next.push(instance.clone()); + next.push(instance); } self.scheduled_overrides.store(next_slot, next)?; Ok(()) @@ -7583,6 +7591,19 @@ mod tests { assert_eq!(restored_account.lamports, 1_000_000); } + #[test] + fn fetch_outcome_retires_only_when_no_later_fetch_can_help() { + for has_local_account in [false, true] { + assert!(FetchOutcome::Retired.is_fetch_retired(has_local_account)); + assert!(!FetchOutcome::Unanswered.is_fetch_retired(has_local_account)); + } + + for outcome in [FetchOutcome::NoRemote, FetchOutcome::NotOnRemote] { + assert!(!outcome.is_fetch_retired(false)); + assert!(outcome.is_fetch_retired(true)); + } + } + /// `Obligation.unhealthy_borrow_value_sf` (u128), counting the discriminator. const UNHEALTHY_OFFSET: usize = 2256; @@ -7906,7 +7927,7 @@ mod tests { let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); assert!( - svm.reschedule_override_for_next_slot(&instance, u64::MAX) + svm.reschedule_override_for_next_slot(instance, u64::MAX) .is_err(), "there is no slot after u64::MAX" ); @@ -8041,10 +8062,10 @@ mod tests { second.account = surfpool_types::AccountAddress::Pubkey(second_account.to_string()); surfnet_svm - .reschedule_override_for_next_slot(&first, SLOT) + .reschedule_override_for_next_slot(first.clone(), SLOT) .expect("reschedule"); surfnet_svm - .reschedule_override_for_next_slot(&second, SLOT) + .reschedule_override_for_next_slot(second, SLOT) .expect("reschedule"); let queued = surfnet_svm @@ -8060,7 +8081,7 @@ mod tests { ); surfnet_svm - .reschedule_override_for_next_slot(&first, SLOT) + .reschedule_override_for_next_slot(first, SLOT) .expect("reschedule"); let queued = surfnet_svm .scheduled_overrides From 50338f8900f2fa074a3ac91d8a04daccbd624d6b Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 16 Sep 2026 11:08:48 +0300 Subject: [PATCH 13/20] fix(goonfi): guard owners, label by pair, trim the builders --- Cargo.lock | 2 - crates/core/Cargo.toml | 3 +- .../src/scenarios/protocols/goonfi/README.md | 100 ++--- .../protocols/goonfi/v1/liquidity.rs | 386 +++++++----------- .../scenarios/protocols/goonfi/v1/markets.rs | 48 +-- .../src/scenarios/protocols/goonfi/v1/mod.rs | 111 +++++ .../scenarios/protocols/goonfi/v1/price.rs | 301 +++++++------- crates/core/src/scenarios/registry.rs | 3 - crates/core/src/surfnet/svm.rs | 4 + crates/core/src/tests/goonfi/mod.rs | 195 ++------- crates/mcp/Cargo.toml | 2 - crates/mcp/src/surfpool/mod.rs | 164 +++----- crates/types/src/scenarios.rs | 4 - 13 files changed, 550 insertions(+), 773 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eda85fc42..7b9e92d19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12219,8 +12219,6 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "solana-account 4.3.1", - "solana-commitment-config", "solana-keypair", "solana-pubkey 3.0.0", "solana-signer", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 36631ffb7..441d7a581 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -115,8 +115,7 @@ axum = { version = "0.8", default-features = false, features = ["tokio", "http1" [dev-dependencies] ed25519-dalek = "1.0.1" -# Only the GoonFi live suite uses it, to host the CPI wrapper that drives the deployed program. -solana-program-runtime = "4.1.2" +solana-program-runtime = "4.2.1" libsecp256k1 = "0.7.2" p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } test-case = { workspace = true } diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md index d917d246e..3ac36cf19 100644 --- a/crates/core/src/scenarios/protocols/goonfi/README.md +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -7,66 +7,32 @@ before a user runs a strategy. Product scenarios do not construct or submit swap ## Pinned deployment -The live tests in `crates/core/src/tests/goonfi/mod.rs` check these ProgramData sizes, -deployment slots and ELF hashes before replaying the program: - -| | Trading program | Oracle publisher | -|---|---|---| -| Program | `goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE` | `dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu` | -| ProgramData | `124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s` | `7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z` | -| ProgramData bytes | 252,429 | 557 | -| Deployment slot | 438563879 | 404369628 | -| ELF SHA-256 | `73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e` | `0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695` | - -A deployment change requires revalidation. These are test pins, not an upgrade-monitoring -service or a claim that every future deployment has the same layout. +The live tests in `crates/core/src/tests/goonfi/mod.rs` pin both programs by ProgramData +size, deployment slot and ELF SHA-256 (the constants at the top of that file) before +replaying the program. A deployment change requires revalidation. These are test pins, not +an upgrade-monitoring service or a claim that every future deployment has the same layout. ## Layouts and templates -A market is 2048 bytes with magic `30 bc 2f 35 34 58 32 9a` at offset 0. Its base/quote -mints are at offsets 80/112, vaults at 144/176, and oracle pointer at 208. The oracle is -32 bytes with no discriminator. Both YAML layouts declare their expected program owner; -the shared materializer checks ownership before writing, then validates size, optional -magic bytes and write bounds. A failed owner check skips the override with a warning. - -| Template | Account | Fields | -|---|---|---| -| `goonfi-price` | Oracle | Bid and ask, u64 at offsets 0 and 8 | -| `goonfi-stale-quote` | Oracle | Freshness slot, u32 at offset 16; default lead -2000 | -| `goonfi-freshness` | Oracle | Freshness slot, u32 at offset 16; default lead 0 | -| `goonfi-reference-band` | Market | Reference prices, u64 at offsets 1712 and 1720 | - -Prices use the human pair price multiplied by `10^6`, independent of mint decimals. -For example, 99.74 quote tokens per base token becomes the integer string `"99740000"`. -Use strings for u64 price values to preserve precision in JSON and Studio. - -Slot templates write exactly four bytes. The u32 multiplier at offset 20 and the -millisecond timestamp at offset 24 remain untouched. A slot value of `null` selects the -template's default lead; an integer specifies a lead relative to the materialization -slot. The resulting slot must fit u32. +`market_overrides.yaml` and `oracle_overrides.yaml` carry the two layouts, every field +offset and the usage notes (`llm_context`) for the four templates: `goonfi-price` and +`goonfi-reference-band` move the oracle bid/ask and the market's reference band as one +invariant; `goonfi-stale-quote` and `goonfi-freshness` write the oracle's 4-byte slot with +a default lead of -2000 or 0. Both layouts declare their program owner; the shared +materializer and builders use the same owner predicate. The materializer then validates size, +optional magic bytes and write bounds. A failed owner check skips the override with a warning. ## Catalog and price scenario -The backend exposes three GoonFi MCP tools: - -- `list_goonfi_markets` discovers program accounts and validates market, oracle and mint - relationships. It returns market/oracle addresses, labels, mint addresses and decimals. - The YAML files contain no market catalog, and discovery does not require a fixed count. -- `create_goonfi_price_scenario` accepts a market address and a positive human price with - up to six decimal places. It resolves the oracle from the market account, validates - both accounts, and composes three overrides: equal oracle bid/ask, equal market reference - prices, and persistent freshness. An omitted market selects the default SOL/USDC market. -- `create_goonfi_liquidity_scenario` accepts a market address and per-vault remaining basis - points. It resolves both token vaults from the market's own pointers (offsets 144 and - 176), reads each current balance, validates the vault and oracle owners, and scales each - vault through `spl-token-account-balance`: 0 drains a vault so a swap rejects with `0x1`, - 10000 leaves it unchanged. A persistent freshness override keeps the rejection about - liquidity rather than a stale quote. Both default to 0; an omitted market selects the - default SOL/USDC market. - -These tools accept optional `surfnetPort`, defaulting to 8899, with camelCase argument names like the pump tool, and read through the local -Surfnet RPC. Missing accounts fall back to that Surfnet's datasource. The price tool -stages through the shared Studio scenario API; Play registers the scenario. +The backend exposes three MCP tools, `list_goonfi_markets`, `create_goonfi_price_scenario` +and `create_goonfi_liquidity_scenario`; their `#[tool(description)]` and parameter +descriptions in `crates/mcp/src/surfpool/mod.rs` document the arguments and defaults. The +YAML files contain no market catalog, and discovery does not require a fixed count. + +These tools accept optional `surfnetPort`, defaulting to 8899, with camelCase argument +names like the pump tool, and read through the local Surfnet RPC. Missing accounts fall +back to that Surfnet's datasource. The scenario tools stage through the shared Studio +scenario API; Play registers the scenario. Direct library callers pass each vault and oracle as `(Pubkey, &Account)`, preserving the address used to read its data. Builders compare these addresses with the market's @@ -75,12 +41,6 @@ authority checks validate the account graph; they do not authenticate arbitrary that a caller deliberately labels with an unrelated address. RPC reads remain outside the pure builders, as in Pump's graduation preparation. -Studio's PMM fair-value dialog selects a protocol, a live market and a human price. It -calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnetPort`, -matching the Tessera dialog convention. Consequently, these Studio GoonFi calls use the -backend's default RPC port. Studio retains only each catalog entry's market address and -label; the backend resolves the oracle when creating a price scenario. - The price builder does not set `fetchBeforeUse`: the accounts read at creation retain local edits, and only the specified fields are changed. Freshness uses `persist: true` to stamp each subsequent materialization slot. These settings do not establish @@ -107,7 +67,7 @@ override does not recalculate percentages at execution time. The live suite fetches deployed account data and runs the pinned trading ELF in LiteSVM, using a builtin wrapper for the Jupiter-shaped CPI. It checks: -- Unchanged encoding produces the same fill; coupled price/reference changes alter output. +- Coupled price/reference changes move the fill linearly in both directions. - Raising only the bid or lowering only the ask rejects with `0x24` (reference-band guard). - Quotes decay with slot age and eventually reject with `0x15`. Changing the multiplier changes decay in the tested fixture; stamping the slot restores freshness. Changing @@ -121,20 +81,12 @@ using a builtin wrapper for the Jupiter-shaped CPI. It checks: Behavior fixtures fund local vaults to at least 10,000 whole tokens and retain wrapped SOL backing. This isolates price, ageing and inventory changes from fluctuating live liquidity; -it does not prove that the same trade currently has sufficient mainnet liquidity. Layout -and discovery checks use unfunded fetched accounts. Owner-predicate unit tests live in -`crates/types/src/scenarios.rs`. This suite does not provide a `pmm-sim` differential run -or a Studio browser test. - -Run all GoonFi unit and live checks serially: - -```bash -SURFPOOL_TEST_RPC_URL= cargo test -p surfpool-core --features integration-tests \ - goonfi -- --test-threads=1 --nocapture -``` +it does not prove that the same trade currently has sufficient mainnet liquidity. -The RPC variable is optional and defaults to the public mainnet endpoint. A private endpoint -can avoid public RPC rate limits. Re-run after a program upgrade or account-layout change. +Run the suite serially with the command at the top of `crates/core/src/tests/goonfi/mod.rs`. +`SURFPOOL_TEST_RPC_URL` is optional and defaults to the public mainnet endpoint; a private +endpoint can avoid public RPC rate limits. Re-run after a program upgrade or account-layout +change. ## Known boundaries diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index 853b7e5c9..4db89a89c 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -11,28 +11,21 @@ use std::collections::HashMap; use solana_account::Account; use solana_pubkey::Pubkey; -use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenario}; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; use super::{ - GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, -}; -use crate::{ - error::{SurfpoolError, SurfpoolResult}, - scenarios::TemplateRegistry, - types::TokenAccount, + FRESHNESS_TEMPLATE, GoonfiMarket, PREPARATION_SLOT, freshness_override, invalid, market_label, + read_pubkey, template, validate_goonfi_market_layout, validate_goonfi_oracle_layout, }; +use crate::{error::SurfpoolResult, scenarios::TemplateRegistry, types::TokenAccount}; /// Read, never written, so no template declares them. -const BASE_MINT_OFFSET: usize = 80; -const QUOTE_MINT_OFFSET: usize = 112; -const BASE_VAULT_OFFSET: usize = 144; -const QUOTE_VAULT_OFFSET: usize = 176; +pub(super) const BASE_MINT_OFFSET: usize = 80; +pub(super) const QUOTE_MINT_OFFSET: usize = 112; +pub(super) const BASE_VAULT_OFFSET: usize = 144; +pub(super) const QUOTE_VAULT_OFFSET: usize = 176; const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; -const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; - -/// Both overrides apply on Play, before any slot advance. -const PREPARATION_SLOT: u64 = 0; /// 10000 basis points leaves a vault untouched; 0 drains it. const FULL_BPS: u16 = 10_000; @@ -49,12 +42,11 @@ pub struct GoonfiLiquidityPreparation { /// The two SPL token vaults a market draws liquidity from, read from the market's own pointers. /// -/// Validates the market first: the shared raw-layout guard has no owner predicate, so the owner -/// check in `validate_goonfi_market_layout` is what keeps these offsets pointed at a real market. +/// Validates the market's owner and byte layout before reading its pointers. pub fn vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> { validate_goonfi_market_layout(market_account)?; - let base = read_pubkey(&market_account.data, BASE_VAULT_OFFSET)?; - let quote = read_pubkey(&market_account.data, QUOTE_VAULT_OFFSET)?; + let base = market_pointer(&market_account.data, BASE_VAULT_OFFSET)?; + let quote = market_pointer(&market_account.data, QUOTE_VAULT_OFFSET)?; if base == Pubkey::default() || quote == Pubkey::default() || base == quote { return Err(invalid("market carries invalid vault pointers")); } @@ -98,8 +90,8 @@ pub fn build_goonfi_liquidity_scenario( } validate_goonfi_oracle_layout(oracle_account.1)?; - let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; - let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let base_mint = market_pointer(&market_account.data, BASE_MINT_OFFSET)?; + let quote_mint = market_pointer(&market_account.data, QUOTE_MINT_OFFSET)?; let base_amount = vault_amount(base_vault_account, base_vault, "base", &base_mint, market)?; let quote_amount = vault_amount( quote_vault_account, @@ -149,22 +141,9 @@ pub fn build_goonfi_liquidity_scenario( ); } - // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null keeps the - // template's own lead of zero. Persisted so the quote stays inside the staleness window and the - // swap the drained state is proven against is rejected for liquidity (0x1), not a stale quote. - scenario.add_override( - OverrideInstance::new( - FRESHNESS_TEMPLATE.to_string(), - PREPARATION_SLOT, - AccountAddress::Pubkey(oracle.to_string()), - ) - .with_values(HashMap::from([( - "last_update_slot".to_string(), - serde_json::Value::Null, - )])) - .with_label("Keep GoonFi quote fresh".to_string()) - .with_persist(true), - ); + // Persisted so the swap the drained state is proven against is rejected for liquidity (0x1), + // not a stale quote. + scenario.add_override(freshness_override(FRESHNESS_TEMPLATE.to_string(), &oracle)); Ok(GoonfiLiquidityPreparation { scenario, @@ -214,82 +193,38 @@ fn remaining_label(bps: u16) -> String { format!("{}.{:02}%", bps / 100, bps % 100) } -fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { - let bytes: [u8; 32] = data - .get(offset..offset + 32) - .and_then(|slice| slice.try_into().ok()) - .ok_or_else(|| invalid("market vault bytes are truncated"))?; - Ok(Pubkey::new_from_array(bytes)) -} - -fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { - registry - .get(id) - .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) -} - -fn invalid(message: impl Into) -> SurfpoolError { - SurfpoolError::internal(message.into()) +fn market_pointer(data: &[u8], offset: usize) -> SurfpoolResult { + read_pubkey(data, offset).ok_or_else(|| invalid("market vault bytes are truncated")) } #[cfg(test)] mod tests { use super::*; - use crate::scenarios::protocols::goonfi::v1::{GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID}; - - const FIXTURE_BASE_VAULT: Pubkey = Pubkey::new_from_array([2; 32]); - const FIXTURE_QUOTE_VAULT: Pubkey = Pubkey::new_from_array([3; 32]); + use crate::scenarios::protocols::goonfi::v1::fixtures::{ + self, FIXTURE_BASE_VAULT, FIXTURE_ORACLE, FIXTURE_QUOTE_VAULT, oracle_account, + }; - const FIXTURE_ORACLE: Pubkey = - Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); const USDC: Pubkey = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); - fn market_account(base_vault: &Pubkey, quote_vault: &Pubkey) -> Account { - let mut data = vec![0u8; 2048]; - // Magic tag every live market shares. - data[0..8].copy_from_slice(&[48, 188, 47, 53, 52, 88, 50, 154]); - data[BASE_MINT_OFFSET..BASE_MINT_OFFSET + 32].copy_from_slice(WSOL.as_ref()); - data[QUOTE_MINT_OFFSET..QUOTE_MINT_OFFSET + 32].copy_from_slice(USDC.as_ref()); - data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].copy_from_slice(base_vault.as_ref()); - data[QUOTE_VAULT_OFFSET..QUOTE_VAULT_OFFSET + 32].copy_from_slice(quote_vault.as_ref()); - data[208..240].copy_from_slice(FIXTURE_ORACLE.as_ref()); - Account { - data, - owner: GOONFI_PROGRAM_ID, - ..Account::default() - } - } - /// The market every fixture below belongs to; its vaults name it as their authority. const MARKET: Pubkey = Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); - fn vault(mint: &Pubkey, amount: u64) -> Account { - const AMOUNT_OFFSET: usize = 64; - const STATE_OFFSET: usize = 108; - let mut data = vec![0u8; 165]; - data[0..32].copy_from_slice(mint.as_ref()); - data[32..64].copy_from_slice(MARKET.as_ref()); - data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); - data[STATE_OFFSET] = 1; - Account { - data, - owner: spl_token_interface::ID, - ..Account::default() - } + fn market_account() -> Account { + fixtures::market_account( + [&WSOL, &USDC], + [&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT], + &FIXTURE_ORACLE, + ) } - fn oracle() -> Account { - Account { - data: vec![0u8; 32], - owner: GOONFI_ORACLE_PROGRAM_ID, - ..Account::default() - } + fn vault(mint: &Pubkey, amount: u64) -> Account { + fixtures::token_account(mint, &MARKET, amount) } #[test] fn rejects_unrelated_vault_with_matching_mint_and_authority() { - let market = market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT); + let market = market_account(); for (base_address, quote_address, side) in [ (Pubkey::new_unique(), FIXTURE_QUOTE_VAULT, "base"), (FIXTURE_BASE_VAULT, Pubkey::new_unique(), "quote"), @@ -299,7 +234,7 @@ mod tests { &market, (base_address, &vault(&WSOL, 999_999_999)), (quote_address, &vault(&USDC, 999_999_999)), - (FIXTURE_ORACLE, &oracle()), + (FIXTURE_ORACLE, &oracle_account()), 5_000, 5_000, ) @@ -313,13 +248,12 @@ mod tests { #[test] fn rejects_an_unrelated_publisher_owned_oracle() { - let market = market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT); let error = build_goonfi_liquidity_scenario( MARKET, - &market, + &market_account(), (FIXTURE_BASE_VAULT, &vault(&WSOL, 1_000)), (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1_000)), - (Pubkey::new_unique(), &oracle()), + (Pubkey::new_unique(), &oracle_account()), 5_000, FULL_BPS, ) @@ -329,31 +263,31 @@ mod tests { #[test] fn drains_both_vaults_and_keeps_the_quote_fresh() { - let base_vault = FIXTURE_BASE_VAULT; - let quote_vault = FIXTURE_QUOTE_VAULT; - let market = MARKET; let preparation = build_goonfi_liquidity_scenario( - market, - &market_account(&base_vault, "e_vault), + MARKET, + &market_account(), (FIXTURE_BASE_VAULT, &vault(&WSOL, 2_441_078_070_812)), (FIXTURE_QUOTE_VAULT, &vault(&USDC, 216_136_231_615)), - (FIXTURE_ORACLE, &oracle()), + (FIXTURE_ORACLE, &oracle_account()), 0, 0, ) .unwrap(); - assert_eq!(preparation.base_vault, base_vault); - assert_eq!(preparation.quote_vault, quote_vault); + assert_eq!(preparation.base_vault, FIXTURE_BASE_VAULT); + assert_eq!(preparation.quote_vault, FIXTURE_QUOTE_VAULT); // A friendly pair label, not the raw market pubkey. assert_eq!(preparation.scenario.name, "GoonFi SOL/USDC liquidity drain"); let [base, quote, freshness] = &preparation.scenario.overrides[..] else { panic!("expected base drain, quote drain and freshness overrides"); }; - assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + assert_eq!( + base.account, + AccountAddress::Pubkey(FIXTURE_BASE_VAULT.to_string()) + ); assert_eq!( quote.account, - AccountAddress::Pubkey(quote_vault.to_string()) + AccountAddress::Pubkey(FIXTURE_QUOTE_VAULT.to_string()) ); assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); @@ -372,14 +306,12 @@ mod tests { #[test] fn scales_partially_and_skips_an_unchanged_side() { - let base_vault = FIXTURE_BASE_VAULT; - let quote_vault = FIXTURE_QUOTE_VAULT; let preparation = build_goonfi_liquidity_scenario( MARKET, - &market_account(&base_vault, "e_vault), + &market_account(), (FIXTURE_BASE_VAULT, &vault(&WSOL, 1_000)), (FIXTURE_QUOTE_VAULT, &vault(&USDC, 999)), - (FIXTURE_ORACLE, &oracle()), + (FIXTURE_ORACLE, &oracle_account()), 2_500, FULL_BPS, ) @@ -388,7 +320,10 @@ mod tests { let [base, freshness] = &preparation.scenario.overrides[..] else { panic!("the unchanged quote side must not get an override"); }; - assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + assert_eq!( + base.account, + AccountAddress::Pubkey(FIXTURE_BASE_VAULT.to_string()) + ); // 1000 * 2500 / 10000, exact integer arithmetic. assert_eq!(base.values.get("amount"), Some(&serde_json::json!("250"))); assert_eq!(freshness.values.len(), 1); @@ -396,127 +331,126 @@ mod tests { #[test] fn rejects_bad_basis_points_and_accounts() { - let base_vault = FIXTURE_BASE_VAULT; - let quote_vault = FIXTURE_QUOTE_VAULT; - let good_market = market_account(&base_vault, "e_vault); - - // Out of range and a no-op leave nothing to prepare. - assert!( - build_goonfi_liquidity_scenario( + let good_market = market_account(); + let foreign_market = Account { + owner: Pubkey::new_unique(), + ..market_account() + }; + let base = vault(&WSOL, 1); + let foreign_vault = Account { + owner: Pubkey::new_unique(), + ..vault(&WSOL, 1) + }; + let quote_mint_vault = vault(&USDC, 1); + let mint_account = Account { + data: vec![0u8; 82], + owner: spl_token_interface::ID, + ..Account::default() + }; + let oracle = oracle_account(); + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle_account() + }; + let rows = [ + // Out of range and a no-op leave nothing to prepare. + ( + "basis points above 10000", MARKET, &good_market, - (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &oracle()), + &base, + &oracle, 10_001, 0, - ) - .is_err() - ); - assert!( - build_goonfi_liquidity_scenario( + ), + ( + "both sides left unchanged", MARKET, &good_market, - (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &oracle()), + &base, + &oracle, FULL_BPS, FULL_BPS, - ) - .is_err() - ); - - // A foreign account of the same size passes the raw guard, so the owner check must reject. - let foreign_market = Account { - owner: Pubkey::new_unique(), - ..market_account(&base_vault, "e_vault) - }; - assert!( - build_goonfi_liquidity_scenario( + ), + // A foreign account of the same size passes the raw guard, so the owner check must reject. + ( + "market owned by another program", MARKET, &foreign_market, - (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &oracle()), + &base, + &oracle, 0, 0, - ) - .is_err() - ); - - // A vault not owned by a token program is not a real vault. - let foreign_vault = Account { - owner: Pubkey::new_unique(), - ..vault(&WSOL, 1) - }; - assert!( - build_goonfi_liquidity_scenario( + ), + // A vault not owned by a token program is not a real vault. + ( + "vault owned by another program", MARKET, &good_market, - (FIXTURE_BASE_VAULT, &foreign_vault), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &oracle()), + &foreign_vault, + &oracle, 0, 0, - ) - .is_err() - ); - - // A foreign oracle carries no magic, so its owner is the only discriminator. - let foreign_oracle = Account { - owner: Pubkey::new_unique(), - ..oracle() - }; - assert!( - build_goonfi_liquidity_scenario( + ), + // A foreign oracle carries no magic, so its owner is the only discriminator. + ( + "oracle owned by another program", MARKET, &good_market, - (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &foreign_oracle), + &base, + &foreign_oracle, 0, 0, - ) - .is_err() - ); - } - - /// An owner-and-length check would pass a mint: it is token-program-owned and long enough to - /// misread an amount out of. Unpacking plus the mint comparison is what rejects it. - #[test] - fn rejects_a_vault_that_is_not_this_markets_token_account() { - let market = market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT); - let mint_account = Account { - data: vec![0u8; 82], - owner: spl_token_interface::ID, - ..Account::default() - }; - assert!( - build_goonfi_liquidity_scenario( + ), + // An owner-and-length check would pass a mint: it is token-program-owned and long + // enough to misread an amount out of. Unpacking plus the mint comparison is what + // rejects it. + ( + "mint account in place of the base vault", MARKET, - &market, - (FIXTURE_BASE_VAULT, &mint_account), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &oracle()), + &good_market, + &mint_account, + &oracle, 0, 0, - ) - .is_err() - ); - - // A real token account holding the other side's mint is refused as well. - assert!( - build_goonfi_liquidity_scenario( + ), + // A real token account holding the other side's mint is refused as well. + ( + "base vault holding the quote mint", MARKET, - &market, - (FIXTURE_BASE_VAULT, &vault(&USDC, 1)), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &oracle()), + &good_market, + "e_mint_vault, + &oracle, 0, 0, - ) - .is_err() - ); + ), + // The market account carries no self-address, so the vault's authority is what ties + // the requested market to these bytes. + ( + "market address that does not hold the vault", + Pubkey::new_unique(), + &good_market, + &base, + &oracle, + 0, + 0, + ), + ]; + for (label, market, market_data, base, oracle, base_bps, quote_bps) in rows { + assert!( + build_goonfi_liquidity_scenario( + market, + market_data, + (FIXTURE_BASE_VAULT, base), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, oracle), + base_bps, + quote_bps, + ) + .is_err(), + "{label}" + ); + } } /// The balance is read from the passed account but written to the vault the market names, so a @@ -532,10 +466,10 @@ mod tests { }; let error = build_goonfi_liquidity_scenario( MARKET, - &market_account(&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT), + &market_account(), (FIXTURE_BASE_VAULT, &vault(&WSOL, 1_000)), (FIXTURE_QUOTE_VAULT, &foreign_quote), - (FIXTURE_ORACLE, &oracle()), + (FIXTURE_ORACLE, &oracle_account()), 0, 0, ) @@ -546,35 +480,13 @@ mod tests { ); } - /// The market account carries no self-address, so the vault's authority is what ties the - /// requested market to these bytes. - #[test] - fn rejects_a_market_address_that_does_not_hold_the_vault() { - let base_vault = FIXTURE_BASE_VAULT; - let quote_vault = FIXTURE_QUOTE_VAULT; - assert!( - build_goonfi_liquidity_scenario( - Pubkey::new_unique(), - &market_account(&base_vault, "e_vault), - (FIXTURE_BASE_VAULT, &vault(&WSOL, 1)), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, &oracle()), - 0, - 0, - ) - .is_err() - ); - } - #[test] fn resolves_vault_addresses_from_the_market() { - let base_vault = FIXTURE_BASE_VAULT; - let quote_vault = FIXTURE_QUOTE_VAULT; - let [base, quote] = vault_addresses(&market_account(&base_vault, "e_vault)).unwrap(); - assert_eq!(base, base_vault); - assert_eq!(quote, quote_vault); + let [base, quote] = vault_addresses(&market_account()).unwrap(); + assert_eq!(base, FIXTURE_BASE_VAULT); + assert_eq!(quote, FIXTURE_QUOTE_VAULT); - let mut zero_pointer = market_account(&base_vault, "e_vault); + let mut zero_pointer = market_account(); zero_pointer.data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].fill(0); assert!(vault_addresses(&zero_pointer).is_err()); } diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index 958c518f7..0dc1f12f2 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -52,7 +52,7 @@ pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { /// The accounts a market points at: its two mints, its oracle, and its base vault. The vault comes /// along to check that its token authority is the market. -fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 4]> { +pub(super) fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 4]> { let oracle = GoonfiMarket::oracle_address(account)?; let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); let quote = Pubkey::new_from_array(account.data[112..144].try_into().unwrap()); @@ -190,7 +190,9 @@ mod tests { use solana_program_pack::Pack; use super::*; - use crate::scenarios::protocols::goonfi::v1::GOONFI_ORACLE_PROGRAM_ID; + use crate::scenarios::protocols::goonfi::v1::fixtures::{ + market_account, oracle_account, token_account, + }; fn fixture() -> (Pubkey, Account, HashMap) { let address = Pubkey::new_unique(); @@ -199,25 +201,7 @@ mod tests { let oracle = Pubkey::new_unique(); let base_vault = Pubkey::new_unique(); let quote_vault = Pubkey::new_unique(); - let registry = TemplateRegistry::new(); - let layout = registry - .get("goonfi-reference-band") - .unwrap() - .raw_layout - .as_ref() - .unwrap(); - let mut market = Account { - owner: GOONFI_PROGRAM_ID, - data: vec![0; layout.account_size], - ..Account::default() - }; - let magic = layout.magic.as_ref().unwrap(); - market.data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); - market.data[80..112].copy_from_slice(base.as_ref()); - market.data[112..144].copy_from_slice(quote.as_ref()); - market.data[144..176].copy_from_slice(base_vault.as_ref()); - market.data[176..208].copy_from_slice(quote_vault.as_ref()); - market.data[208..240].copy_from_slice(oracle.as_ref()); + let market = market_account([&base, "e], [&base_vault, "e_vault], &oracle); let mint = |decimals| { let mut account = Account { owner: spl_token_interface::ID, @@ -232,32 +216,14 @@ mod tests { .pack_into_slice(&mut account.data); account }; - let vault = { - let mut data = vec![0u8; 165]; - data[0..32].copy_from_slice(base.as_ref()); - data[32..64].copy_from_slice(address.as_ref()); - data[108] = 1; - Account { - owner: spl_token_interface::ID, - data, - ..Account::default() - } - }; ( address, market, HashMap::from([ (base, mint(9)), (quote, mint(6)), - (base_vault, vault), - ( - oracle, - Account { - owner: GOONFI_ORACLE_PROGRAM_ID, - data: vec![0; 32], - ..Account::default() - }, - ), + (base_vault, token_account(&base, &address, 0)), + (oracle, oracle_account()), ]), ) } diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index ef84741c3..c5c1fbc6a 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -2,6 +2,8 @@ mod liquidity; mod markets; mod price; +use std::collections::HashMap; + pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses}; pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; pub use price::{ @@ -9,3 +11,112 @@ pub use price::{ GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, validate_goonfi_oracle_layout, }; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Every builder override applies on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +fn read_pubkey(data: &[u8], offset: usize) -> Option { + data.get(offset..offset + 32) + .and_then(|slice| slice.try_into().ok()) + .map(Pubkey::new_from_array) +} + +/// Null, not zero: the slot encoder reads a supplied number AS the lead, so only null takes the +/// template's own lead of zero. Persisted, so the prepared quote stays inside the oracle's +/// staleness window however long the scenario is left running. +fn freshness_override(template_id: String, oracle: &Pubkey) -> OverrideInstance { + OverrideInstance::new( + template_id, + PREPARATION_SLOT, + AccountAddress::Pubkey(oracle.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true) +} + +#[cfg(test)] +mod fixtures { + use solana_account::Account; + use solana_pubkey::Pubkey; + + use super::{ + GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, + liquidity::{BASE_MINT_OFFSET, BASE_VAULT_OFFSET, QUOTE_MINT_OFFSET, QUOTE_VAULT_OFFSET}, + price::{MARKET_LAYOUT, ORACLE_LAYOUT, ORACLE_POINTER_OFFSET}, + }; + + pub(super) const FIXTURE_BASE_VAULT: Pubkey = Pubkey::new_from_array([2; 32]); + pub(super) const FIXTURE_QUOTE_VAULT: Pubkey = Pubkey::new_from_array([3; 32]); + pub(super) const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + + /// A market of the manifest's size and magic, pointing at the given mints, vaults and oracle. + pub(super) fn market_account( + mints: [&Pubkey; 2], + vaults: [&Pubkey; 2], + oracle: &Pubkey, + ) -> Account { + let mut data = vec![0; MARKET_LAYOUT.account_size]; + let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + for (offset, pointer) in [ + (BASE_MINT_OFFSET, mints[0]), + (QUOTE_MINT_OFFSET, mints[1]), + (BASE_VAULT_OFFSET, vaults[0]), + (QUOTE_VAULT_OFFSET, vaults[1]), + (ORACLE_POINTER_OFFSET, oracle), + ] { + data[offset..offset + 32].copy_from_slice(pointer.as_ref()); + } + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + pub(super) fn oracle_account() -> Account { + Account { + data: vec![0; ORACLE_LAYOUT.account_size], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + /// An initialized SPL token account holding `amount` of `mint` for `authority`. + pub(super) fn token_account(mint: &Pubkey, authority: &Pubkey, amount: u64) -> Account { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(authority.as_ref()); + data[64..72].copy_from_slice(&amount.to_le_bytes()); + data[108] = 1; + Account { + data, + owner: spl_token_interface::ID, + ..Account::default() + } + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index d033c412f..10511ef96 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -10,9 +10,12 @@ use std::{collections::HashMap, sync::LazyLock}; use solana_account::Account; use solana_pubkey::Pubkey; -use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayout, Scenario}; +use surfpool_types::{AccountAddress, OverrideInstance, RawLayout, Scenario}; -use super::vault_addresses; +use super::{ + FRESHNESS_TEMPLATE, PREPARATION_SLOT, freshness_override, invalid, market_label, + markets::market_references, read_pubkey, template, vault_addresses, +}; use crate::{ error::{SurfpoolError, SurfpoolResult}, scenarios::TemplateRegistry, @@ -28,13 +31,14 @@ pub const GOONFI_DEFAULT_MARKET: Pubkey = Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); /// Read, never written, so no template declares it. -const ORACLE_POINTER_OFFSET: usize = 208; +pub(super) const ORACLE_POINTER_OFFSET: usize = 208; /// The layouts a GoonFi market and its oracle must have, taken from the manifests the raw /// templates are written against so there is one definition of them. Built once; both manifests /// are compiled in. -static ORACLE_LAYOUT: LazyLock = LazyLock::new(|| layout_of(PRICE_TEMPLATE)); -static MARKET_LAYOUT: LazyLock = LazyLock::new(|| layout_of(REFERENCE_TEMPLATE)); +pub(super) static ORACLE_LAYOUT: LazyLock = LazyLock::new(|| layout_of(PRICE_TEMPLATE)); +pub(super) static MARKET_LAYOUT: LazyLock = + LazyLock::new(|| layout_of(REFERENCE_TEMPLATE)); fn layout_of(template_id: &str) -> RawLayout { template(&TemplateRegistry::new(), template_id) @@ -49,14 +53,10 @@ fn layout_of(template_id: &str) -> RawLayout { const PRICE_TEMPLATE: &str = "goonfi-price"; const REFERENCE_TEMPLATE: &str = "goonfi-reference-band"; -const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; /// Prices are the human pair price times 10^6, independent of mint decimals. const PRICE_SCALE_DECIMALS: u32 = 6; -/// All three overrides apply on Play, before any slot advance. -const PREPARATION_SLOT: u64 = 0; - /// The parts of a GoonFi market a price move needs: the market account itself and the oracle it /// points at. /// @@ -69,6 +69,8 @@ const PREPARATION_SLOT: u64 = 0; pub struct GoonfiMarket { address: Pubkey, oracle: Pubkey, + base_mint: Pubkey, + quote_mint: Pubkey, } impl GoonfiMarket { @@ -77,7 +79,8 @@ impl GoonfiMarket { /// plus the owner check below are what keep a write out of a foreign account. pub fn oracle_address(market_account: &Account) -> SurfpoolResult { validate_goonfi_market_layout(market_account)?; - let oracle = read_pubkey(&market_account.data, ORACLE_POINTER_OFFSET)?; + let oracle = read_pubkey(&market_account.data, ORACLE_POINTER_OFFSET) + .ok_or_else(|| invalid("market oracle bytes are truncated"))?; if oracle == Pubkey::default() { return Err(invalid("market carries no oracle pointer")); } @@ -108,7 +111,13 @@ impl GoonfiMarket { } validate_market_authority(address, base_vault_account.1)?; validate_goonfi_oracle_layout(oracle_account.1)?; - Ok(Self { address, oracle }) + let [base_mint, quote_mint, _, _] = market_references(market_account)?; + Ok(Self { + address, + oracle, + base_mint, + quote_mint, + }) } /// Read-only: the pair is fixed at validation so a caller can inspect it but not re-point it. @@ -119,6 +128,10 @@ impl GoonfiMarket { pub fn oracle(&self) -> Pubkey { self.oracle } + + pub fn label(&self) -> String { + market_label(&self.base_mint, &self.quote_mint) + } } fn validate_market_authority(address: Pubkey, base_vault_account: &Account) -> SurfpoolResult<()> { @@ -142,13 +155,12 @@ fn validate_market_authority(address: Pubkey, base_vault_account: &Account) -> S /// Rejects an account that is not a GoonFi market. /// -/// The shared raw-layout guard has no owner predicate, so a foreign account of the same size -/// carrying the same magic would pass it. Every builder-made scenario comes through here, which -/// adds the ownership check the schema cannot express. +/// The owner and byte guards use the same manifest as the materializer, so a builder cannot +/// accept an account that the template's owner predicate would later reject. pub fn validate_goonfi_market_layout(account: &Account) -> SurfpoolResult<()> { - if account.owner != GOONFI_PROGRAM_ID { - return Err(invalid("market is not owned by GoonFi")); - } + MARKET_LAYOUT + .guard_owner(&account.owner) + .map_err(|_| invalid("market is not owned by GoonFi"))?; MARKET_LAYOUT.guard(&account.data).map_err(invalid) } @@ -157,9 +169,9 @@ pub fn validate_goonfi_market_layout(account: &Account) -> SurfpoolResult<()> { /// The oracle is 32 bytes with no magic at all, so its guard pins only the size; the owner check /// here is the real discriminator. pub fn validate_goonfi_oracle_layout(account: &Account) -> SurfpoolResult<()> { - if account.owner != GOONFI_ORACLE_PROGRAM_ID { - return Err(invalid("oracle is not owned by the GoonFi publisher")); - } + ORACLE_LAYOUT + .guard_owner(&account.owner) + .map_err(|_| invalid("oracle is not owned by the GoonFi publisher"))?; ORACLE_LAYOUT.guard(&account.data).map_err(invalid) } @@ -182,28 +194,25 @@ pub fn build_goonfi_price_scenario( let price_template = template(®istry, PRICE_TEMPLATE)?; let reference = template(®istry, REFERENCE_TEMPLATE)?; let freshness = template(®istry, FRESHNESS_TEMPLATE)?; - let market_name = market.address.to_string(); + let market_name = market.label(); let oracle_target = AccountAddress::Pubkey(market.oracle.to_string()); // No fetch_before_use anywhere: the oracle and reference values are absolute targets for the // account graph creation read, and a Play-time refetch would reinstall remote bytes over any // local edit. - let price_override = OverrideInstance::new( - price_template.id.clone(), - PREPARATION_SLOT, - oracle_target.clone(), - ) - .with_values(HashMap::from([ - ( - "bid_price_x1e6".to_string(), - serde_json::json!(scaled.clone()), - ), - ( - "ask_price_x1e6".to_string(), - serde_json::json!(scaled.clone()), - ), - ])) - .with_label(format!("GoonFi {market_name} price")); + let price_override = + OverrideInstance::new(price_template.id.clone(), PREPARATION_SLOT, oracle_target) + .with_values(HashMap::from([ + ( + "bid_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "ask_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ])) + .with_label(format!("GoonFi {market_name} price")); // The deployed program rejects an oracle price outside the market's reference band with // custom error 0x24, so the band moves to the same target as one invariant. @@ -224,18 +233,6 @@ pub fn build_goonfi_price_scenario( ])) .with_label(format!("GoonFi {market_name} reference band")); - // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null takes - // the template's own lead of zero. Persisted, so the prepared price stays inside the oracle's - // staleness window however long the scenario is left running. - let freshness_override = - OverrideInstance::new(freshness.id.clone(), PREPARATION_SLOT, oracle_target) - .with_values(HashMap::from([( - "last_update_slot".to_string(), - serde_json::Value::Null, - )])) - .with_label("Keep GoonFi quote fresh".to_string()) - .with_persist(true); - let normalized_price = price.trim(); let mut scenario = Scenario::new( format!("GoonFi {market_name} at {normalized_price}"), @@ -251,7 +248,7 @@ pub fn build_goonfi_price_scenario( ]; scenario.add_override(price_override); scenario.add_override(reference_override); - scenario.add_override(freshness_override); + scenario.add_override(freshness_override(freshness.id.clone(), &market.oracle)); Ok(GoonfiPricePreparation { scenario, @@ -261,14 +258,7 @@ pub fn build_goonfi_price_scenario( }) } -fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { - let bytes: [u8; 32] = data[offset..offset + 32] - .try_into() - .map_err(|_| invalid("market oracle bytes are truncated"))?; - Ok(Pubkey::new_from_array(bytes)) -} - -pub(super) fn human_price_to_x1e6(price: &str) -> SurfpoolResult { +fn human_price_to_x1e6(price: &str) -> SurfpoolResult { let value = price.trim(); let mut parts = value.split('.'); let whole = parts.next().unwrap_or_default(); @@ -307,61 +297,40 @@ pub(super) fn human_price_to_x1e6(price: &str) -> SurfpoolResult { }) } -fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { - registry - .get(id) - .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) -} - -fn invalid(message: impl Into) -> SurfpoolError { - SurfpoolError::internal(message.into()) -} - #[cfg(test)] mod tests { use super::*; + use crate::scenarios::protocols::goonfi::v1::fixtures::{ + self, FIXTURE_BASE_VAULT, FIXTURE_ORACLE, FIXTURE_QUOTE_VAULT, oracle_account, + }; - const FIXTURE_BASE_VAULT: Pubkey = Pubkey::new_from_array([2; 32]); - const FIXTURE_QUOTE_VAULT: Pubkey = Pubkey::new_from_array([3; 32]); - - fn market_account(oracle: &Pubkey) -> Account { - let mut data = vec![0; MARKET_LAYOUT.account_size]; - let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); - data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); - data[144..176].copy_from_slice(FIXTURE_BASE_VAULT.as_ref()); - data[176..208].copy_from_slice(FIXTURE_QUOTE_VAULT.as_ref()); - data[ORACLE_POINTER_OFFSET..ORACLE_POINTER_OFFSET + 32].copy_from_slice(oracle.as_ref()); - Account { - data, - owner: GOONFI_PROGRAM_ID, - ..Account::default() + #[test] + fn template_owners_match_the_discovered_programs() { + let registry = TemplateRegistry::new(); + for (id, owner) in [ + (REFERENCE_TEMPLATE, GOONFI_PROGRAM_ID), + (PRICE_TEMPLATE, GOONFI_ORACLE_PROGRAM_ID), + (FRESHNESS_TEMPLATE, GOONFI_ORACLE_PROGRAM_ID), + ("goonfi-stale-quote", GOONFI_ORACLE_PROGRAM_ID), + ] { + let layout = registry.get(id).unwrap().raw_layout.as_ref().unwrap(); + assert_eq!(layout.owner, Some(owner.to_string()), "{id}"); } } - fn oracle_account() -> Account { - Account { - data: vec![0; ORACLE_LAYOUT.account_size], - owner: GOONFI_ORACLE_PROGRAM_ID, - ..Account::default() - } + fn market_account(oracle: &Pubkey) -> Account { + fixtures::market_account( + [&Pubkey::new_unique(), &Pubkey::new_unique()], + [&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT], + oracle, + ) } /// A market's base vault: a token account whose authority is the market itself. fn vault_account(authority: &Pubkey) -> Account { - let mut data = vec![0u8; 165]; - data[0..32].copy_from_slice(Pubkey::new_unique().as_ref()); - data[32..64].copy_from_slice(authority.as_ref()); - data[108] = 1; - Account { - data, - owner: spl_token_interface::ID, - ..Account::default() - } + fixtures::token_account(&Pubkey::new_unique(), authority, 0) } - const FIXTURE_ORACLE: Pubkey = - Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); - fn market() -> GoonfiMarket { let address = Pubkey::new_unique(); GoonfiMarket::validate( @@ -373,6 +342,44 @@ mod tests { .expect("valid GoonFi market") } + #[test] + fn names_the_scenario_and_overrides_by_the_pair_label() { + const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + const USDC: Pubkey = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + let address = Pubkey::new_unique(); + let market = GoonfiMarket::validate( + address, + &fixtures::market_account( + [&WSOL, &USDC], + [&FIXTURE_BASE_VAULT, &FIXTURE_QUOTE_VAULT], + &FIXTURE_ORACLE, + ), + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (FIXTURE_ORACLE, &oracle_account()), + ) + .unwrap(); + + let scenario = build_goonfi_price_scenario(&market, "150") + .unwrap() + .scenario; + let labels: Vec<&str> = scenario + .overrides + .iter() + .map(|instance| instance.label.as_deref().unwrap_or("")) + .collect(); + + assert_eq!(scenario.name, "GoonFi SOL/USDC at 150"); + assert_eq!( + labels, + [ + "GoonFi SOL/USDC price", + "GoonFi SOL/USDC reference band", + "Keep GoonFi quote fresh" + ] + ); + assert!(scenario.description.contains(&address.to_string())); + } + #[test] fn rejects_unrelated_base_vault_with_target_market_authority() { let target_market = Pubkey::new_unique(); @@ -441,6 +448,9 @@ mod tests { assert!(validate_market_authority(Pubkey::new_unique(), &oracle_account()).is_err()); } + /// The values are absolute targets for the creation read, so nothing refetches at Play; the + /// freshness value must stay null because the slot encoder reads a supplied number as the + /// lead rather than ignoring it. #[test] fn builds_price_scenario_across_both_accounts() { let market = market(); @@ -470,17 +480,6 @@ mod tests { reference.values.get("reference_price_b_x1e6"), Some(&serde_json::json!("99740000")) ); - } - - /// The values are absolute targets for the creation read, so nothing refetches at Play; the - /// freshness value must stay null because the slot encoder reads a supplied number as the - /// lead rather than ignoring it. - #[test] - fn price_stays_on_the_creation_read_and_freshness_keeps_the_template_lead() { - let preparation = build_goonfi_price_scenario(&market(), "1").unwrap(); - let [price, reference, freshness] = &preparation.scenario.overrides[..] else { - panic!("expected exactly three overrides"); - }; assert!(!price.fetch_before_use); assert!(!price.persist); assert!(!reference.fetch_before_use); @@ -532,72 +531,68 @@ mod tests { let uncataloged = GoonfiMarket { address: Pubkey::new_unique(), oracle: Pubkey::new_unique(), + base_mint: Pubkey::new_unique(), + quote_mint: Pubkey::new_unique(), }; let preparation = build_goonfi_price_scenario(&uncataloged, "1").unwrap(); + assert!(preparation.scenario.name.contains(&uncataloged.label())); assert!( preparation .scenario - .name + .description .contains(&uncataloged.address.to_string()) ); assert_eq!(preparation.oracle, uncataloged.oracle); let oracle = Pubkey::new_unique(); + let address = Pubkey::new_unique(); let wrong_owner = Account { owner: Pubkey::new_unique(), ..market_account(&oracle) }; - let address = Pubkey::new_unique(); - assert!( - GoonfiMarket::validate( - address, - &wrong_owner, - (FIXTURE_BASE_VAULT, &vault_account(&address)), - (oracle, &oracle_account()), - ) - .is_err() - ); // The raw guard cannot see the owner, which is the whole reason this check sits on top. assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); - let mut bad_magic = market_account(&oracle); bad_magic.data[0] ^= 0xff; - assert!( - GoonfiMarket::validate( - address, - &bad_magic, - (FIXTURE_BASE_VAULT, &vault_account(&address)), - (oracle, &oracle_account()), - ) - .is_err() - ); - - let no_pointer = market_account(&Pubkey::default()); - assert!( - GoonfiMarket::validate( - address, - &no_pointer, - (FIXTURE_BASE_VAULT, &vault_account(&address)), - (oracle, &oracle_account()), - ) - .is_err() - ); - // The oracle carries no magic at all, so the owner check is its only discriminator. let foreign_oracle = Account { owner: Pubkey::new_unique(), ..oracle_account() }; - assert!( - GoonfiMarket::validate( - address, - &market_account(&oracle), - (FIXTURE_BASE_VAULT, &vault_account(&address)), - (oracle, &foreign_oracle), - ) - .is_err() - ); assert!(ORACLE_LAYOUT.guard(&foreign_oracle.data).is_ok()); + for (label, market, oracle_fixture) in [ + ( + "market owned by another program", + wrong_owner, + oracle_account(), + ), + ( + "market with a flipped magic byte", + bad_magic, + oracle_account(), + ), + ( + "market without an oracle pointer", + market_account(&Pubkey::default()), + oracle_account(), + ), + ( + "oracle owned by another program", + market_account(&oracle), + foreign_oracle, + ), + ] { + assert!( + GoonfiMarket::validate( + address, + &market, + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (oracle, &oracle_fixture), + ) + .is_err(), + "{label}" + ); + } let truncated_oracle = Account { data: vec![0; 16], diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 4824e1368..f9e45a9ad 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -540,10 +540,7 @@ mod tests { "Registry should load 66 templates total" ); - assert!(registry.contains("goonfi-price")); assert!(registry.contains("goonfi-stale-quote")); - assert!(registry.contains("goonfi-freshness")); - assert!(registry.contains("goonfi-reference-band")); assert!(registry.contains("pyth-price-feed-v2")); diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index da32e51cb..c1a75dc21 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -4536,6 +4536,10 @@ impl SurfnetSvm { Ok(fixtures) } + /// Registers a scenario for execution by scheduling its overrides + /// + /// The `slot` parameter is the base slot from which relative override slot heights are calculated. + /// If not provided, uses the current slot. pub fn register_scenario( &mut self, scenario: surfpool_types::Scenario, diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs index 9b067c16e..ef4c7ae66 100644 --- a/crates/core/src/tests/goonfi/mod.rs +++ b/crates/core/src/tests/goonfi/mod.rs @@ -23,7 +23,7 @@ use crate::{ scenarios::{ TemplateRegistry, protocols::goonfi::v1::{ - GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, vault_addresses, + GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, }, }, surfnet::svm::SurfnetSvm, @@ -33,7 +33,6 @@ use crate::{ const GOONFI_PROGRAM: &str = "goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"; const GOONFI_PROGRAMDATA: &str = "124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s"; const ORACLE_PROGRAMDATA: &str = "7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z"; -const GOONFI_ORACLE_PROGRAM: &str = "dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"; const GOONFI_GLOBAL: &str = "BNrK9LpEn65QA4TyBLVSMdngW3XHj3xLfFPwGdCBv8wV"; const JUPITER_PROGRAM: &str = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; @@ -56,6 +55,7 @@ const ORACLE_ASK_OFFSET: usize = 8; const ORACLE_SLOT_OFFSET: usize = 16; const ORACLE_MULTIPLIER_OFFSET: usize = 20; const ORACLE_TS_MS_OFFSET: usize = 24; +const ORACLE_PRICE_OFFSETS: [usize; 2] = [ORACLE_BID_OFFSET, ORACLE_ASK_OFFSET]; /// Market-account fields the flows touch or read. The two reference prices band-guard the oracle; /// the mint and oracle pointers identify the pair. @@ -64,6 +64,7 @@ const MARKET_QUOTE_MINT_OFFSET: usize = 112; const MARKET_ORACLE_OFFSET: usize = 208; const MARKET_REF_A_OFFSET: usize = 1712; const MARKET_REF_B_OFFSET: usize = 1720; +const MARKET_REFERENCE_OFFSETS: [usize; 2] = [MARKET_REF_A_OFFSET, MARKET_REF_B_OFFSET]; #[derive(Clone, Copy)] struct MarketSpec { @@ -264,10 +265,6 @@ fn native_token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { data } -fn token_amount(data: &[u8]) -> u64 { - u64::from_le_bytes(data[64..72].try_into().unwrap()) -} - fn read_u64(data: &[u8], offset: usize) -> u64 { u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) } @@ -284,16 +281,8 @@ fn oracle_slot(data: &[u8]) -> u64 { u64::from(read_u32(data, ORACLE_SLOT_OFFSET)) } -fn scale_prices(data: &mut [u8], numerator: u64, denominator: u64) { - for offset in [ORACLE_BID_OFFSET, ORACLE_ASK_OFFSET] { - let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) - / u128::from(denominator)) as u64; - write_u64(data, offset, scaled); - } -} - -fn scale_refs(data: &mut [u8], numerator: u64, denominator: u64) { - for offset in [MARKET_REF_A_OFFSET, MARKET_REF_B_OFFSET] { +fn scale_u64s(data: &mut [u8], offsets: [usize; 2], numerator: u64, denominator: u64) { + for offset in offsets { let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) / u128::from(denominator)) as u64; write_u64(data, offset, scaled); @@ -524,10 +513,11 @@ fn goonfi_run_capturing_oracle( } else { user_base_key }; - let amount_out = token_amount( + let amount_out = read_u64( &svm.get_account(&destination) .expect("destination account") .data, + 64, ); let oracle_after = svm.get_account(&oracle_key).expect("oracle account").data; Ok((amount_out, oracle_after)) @@ -600,88 +590,6 @@ fn assert_rejects_with(result: Result, code: &str, context: &str) { } } -#[tokio::test] -async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() { - let fork = goonfi_fork(PRIMARY_MARKET).await; - let registry = TemplateRegistry::new(); - let price = registry.get("goonfi-price").expect("price template"); - let stale = registry.get("goonfi-stale-quote").expect("stale template"); - let fresh = registry - .get("goonfi-freshness") - .expect("freshness template"); - let band = registry - .get("goonfi-reference-band") - .expect("reference-band template"); - - let oracle_layout = price.raw_layout.as_ref().expect("oracle raw layout"); - let market_layout = band.raw_layout.as_ref().expect("market raw layout"); - assert!(oracle_layout.guard(&fork.oracle.data).is_ok()); - assert!(market_layout.guard(&fork.market.data).is_ok()); - assert!(oracle_layout.guard(&fork.oracle.data[..16]).is_err()); - assert!(market_layout.guard(&fork.market.data[..2000]).is_err()); - let mut flipped = fork.market.data.clone(); - flipped[0] ^= 0xff; - assert!(market_layout.guard(&flipped).is_err()); - - let priced = oracle_layout - .materialize( - &fork.oracle.data, - &price.properties, - &HashMap::from([ - ("bid_price_x1e6".to_string(), serde_json::json!("123456789")), - ("ask_price_x1e6".to_string(), serde_json::json!("123456790")), - ]), - 0, - ) - .expect("materialize price"); - assert_eq!(read_u64(&priced, ORACLE_BID_OFFSET), 123_456_789); - assert_eq!(read_u64(&priced, ORACLE_ASK_OFFSET), 123_456_790); - assert_only_ranges_changed(&fork.oracle.data, &priced, &[(0, 16)]); - - // The freshness slot is 4 bytes wide: the dynamic multiplier right after it must survive. - let target_slot = 500_000_123; - for (template, label) in [(stale, "stale"), (fresh, "freshness")] { - let stamped = template - .raw_layout - .as_ref() - .expect("oracle raw layout") - .materialize( - &fork.oracle.data, - &template.properties, - &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), - target_slot, - ) - .unwrap_or_else(|error| panic!("materialize {label}: {error}")); - assert_only_ranges_changed(&fork.oracle.data, &stamped, &[(16, 20)]); - assert_eq!( - read_u32(&stamped, ORACLE_MULTIPLIER_OFFSET), - read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET), - "{label} clobbered the staleness multiplier" - ); - } - - let banded = market_layout - .materialize( - &fork.market.data, - &band.properties, - &HashMap::from([ - ( - "reference_price_a_x1e6".to_string(), - serde_json::json!("123456789"), - ), - ( - "reference_price_b_x1e6".to_string(), - serde_json::json!("123456789"), - ), - ]), - 0, - ) - .expect("materialize reference band"); - assert_eq!(read_u64(&banded, MARKET_REF_A_OFFSET), 123_456_789); - assert_eq!(read_u64(&banded, MARKET_REF_B_OFFSET), 123_456_789); - assert_only_ranges_changed(&fork.market.data, &banded, &[(1712, 1728)]); -} - /// Proves the exact state the real builder prepares, end to end: `build_goonfi_price_scenario` /// output registers and materializes through the production path, touching only its declared /// bytes, and the deployed program then fills at the prepared price. The scenario is anchored at @@ -699,6 +607,26 @@ async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { (oracle_key, &fork.oracle), ) .expect("validate market"); + let registry = TemplateRegistry::new(); + let price_template = registry.get("goonfi-price").expect("price template"); + let priced = price_template + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &price_template.properties, + &HashMap::from([ + ("bid_price_x1e6".to_string(), serde_json::json!("123456789")), + ("ask_price_x1e6".to_string(), serde_json::json!("123456790")), + ]), + 0, + ) + .expect("materialize asymmetric price"); + assert_eq!(read_u64(&priced, ORACLE_BID_OFFSET), 123_456_789); + assert_eq!(read_u64(&priced, ORACLE_ASK_OFFSET), 123_456_790); + assert_only_ranges_changed(&fork.oracle.data, &priced, &[(0, 16)]); + let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); let target = live_bid * 3 / 2; let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); @@ -771,12 +699,6 @@ async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { assert_only_ranges_changed(&oracle, &next, &[(16, 20)]); } -#[tokio::test] -async fn goonfi_builder_scenario_materializes_and_fills_across_oracle_and_market() { - let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); - builder_prepares_and_the_program_fills(&fork).await; -} - #[tokio::test] async fn goonfi_price_and_reference_band_control_the_deployed_program() { let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); @@ -785,20 +707,12 @@ async fn goonfi_price_and_reference_band_control_the_deployed_program() { let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("baseline sell"); assert!(baseline > 0); - // No-op rewrite proves the encoding round-trips; the program cannot tell the bytes moved. - let noop = goonfi_run(&fork, RunConfig::sell(amount), |oracle| { - let restated = read_u64(oracle, ORACLE_BID_OFFSET); - write_u64(oracle, ORACLE_BID_OFFSET, restated); - }) - .expect("no-op sell"); - assert_eq!(noop, baseline); - // Coupled halve and double move the fill linearly in both directions. let halved = goonfi_run_full( &fork, RunConfig::sell(amount), - |oracle| scale_prices(oracle, 1, 2), - |market| scale_refs(market, 1, 2), + |oracle| scale_u64s(oracle, ORACLE_PRICE_OFFSETS, 1, 2), + |market| scale_u64s(market, MARKET_REFERENCE_OFFSETS, 1, 2), ) .expect("coupled halved sell"); assert!( @@ -808,8 +722,8 @@ async fn goonfi_price_and_reference_band_control_the_deployed_program() { let doubled = goonfi_run_full( &fork, RunConfig::sell(amount), - |oracle| scale_prices(oracle, 2, 1), - |market| scale_refs(market, 2, 1), + |oracle| scale_u64s(oracle, ORACLE_PRICE_OFFSETS, 2, 1), + |market| scale_u64s(market, MARKET_REFERENCE_OFFSETS, 2, 1), ) .expect("coupled doubled sell"); assert!( @@ -820,14 +734,14 @@ async fn goonfi_price_and_reference_band_control_the_deployed_program() { // Decoupled moves reject: the band guards each direction against the venue-unfavorable side. assert_rejects_with( goonfi_run(&fork, RunConfig::sell(amount), |oracle| { - scale_prices(oracle, 2, 1) + scale_u64s(oracle, ORACLE_PRICE_OFFSETS, 2, 1) }), ERROR_PRICE_OUT_OF_BAND, "sell with raised oracle and untouched reference band", ); assert_rejects_with( goonfi_run(&fork, RunConfig::buy(100_000_000), |oracle| { - scale_prices(oracle, 1, 2) + scale_u64s(oracle, ORACLE_PRICE_OFFSETS, 1, 2) }), ERROR_PRICE_OUT_OF_BAND, "buy with lowered oracle and untouched reference band", @@ -835,8 +749,8 @@ async fn goonfi_price_and_reference_band_control_the_deployed_program() { let coupled_buy = goonfi_run_full( &fork, RunConfig::buy(100_000_000), - |oracle| scale_prices(oracle, 1, 2), - |market| scale_refs(market, 1, 2), + |oracle| scale_u64s(oracle, ORACLE_PRICE_OFFSETS, 1, 2), + |market| scale_u64s(market, MARKET_REFERENCE_OFFSETS, 1, 2), ) .expect("coupled halved buy"); assert!(coupled_buy > 0); @@ -873,6 +787,8 @@ async fn goonfi_price_and_reference_band_control_the_deployed_program() { ERROR_INSUFFICIENT_LIQUIDITY, "sell against a drained quote vault", ); + + builder_prepares_and_the_program_fills(&fork).await; } fn stamp_multiplier(data: &mut [u8], multiplier: u32) { @@ -1018,8 +934,8 @@ async fn goonfi_second_market_proves_generic_price_and_staleness_layout() { let halved = goonfi_run_full( &fork, RunConfig::sell(amount), - |oracle| scale_prices(oracle, 1, 2), - |market| scale_refs(market, 1, 2), + |oracle| scale_u64s(oracle, ORACLE_PRICE_OFFSETS, 1, 2), + |market| scale_u64s(market, MARKET_REFERENCE_OFFSETS, 1, 2), ) .expect("SOL/USDC coupled halved sell"); assert!( @@ -1094,39 +1010,6 @@ async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { market.oracle ); } - for chunk in markets.chunks(40) { - let addresses: Vec = chunk - .iter() - .flat_map(|market| [market.address, market.oracle]) - .collect(); - let accounts = live::fetch(&addresses).await; - // Include each base vault to validate its token authority against the discovered market. - let vaults: Vec = accounts - .chunks_exact(2) - .map(|pair| vault_addresses(&pair[0]).expect("a live market names its vaults")[0]) - .collect(); - let vault_accounts = live::fetch(&vaults).await; - for ((discovered, accounts), (vault_address, vault)) in chunk - .iter() - .zip(accounts.chunks_exact(2)) - .zip(vaults.iter().zip(vault_accounts.iter())) - { - let validated = GoonfiMarket::validate( - discovered.address, - &accounts[0], - (*vault_address, vault), - (discovered.oracle, &accounts[1]), - ) - .expect("discovered market and oracle must retain their live owners and layouts"); - assert_eq!( - validated.oracle(), - discovered.oracle, - "live market oracle pointer changed" - ); - assert_eq!(&accounts[0].data[80..112], discovered.base_mint.as_ref()); - assert_eq!(&accounts[0].data[112..144], discovered.quote_mint.as_ref()); - } - } eprintln!( "GoonFi real RPC discovery verified {} unique live market/oracle pairs", markets.len() diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 37651d608..0ef4ebe29 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -21,8 +21,6 @@ rmcp = { workspace = true, features = ["transport-io", "transport-sse-server", " serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" -solana-account = { workspace = true } -solana-commitment-config = { workspace = true } solana-keypair = { workspace = true } solana-pubkey = { workspace = true } solana-signer = { workspace = true } diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 4b232f005..021e38357 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -21,8 +21,8 @@ use surfpool_core::{ TemplateRegistry, protocols::{ goonfi::v1::{ - GoonfiMarket, build_goonfi_liquidity_scenario, build_goonfi_price_scenario, - discover_goonfi_markets, vault_addresses, + GOONFI_DEFAULT_MARKET, GoonfiMarket, build_goonfi_liquidity_scenario, + build_goonfi_price_scenario, discover_goonfi_markets, vault_addresses, }, pump::v1::graduation_builder::{ build_pump_graduation_scenario, pump_graduation_addresses, @@ -390,6 +390,14 @@ fn scenario_tool_error(message: String) -> CallToolResult { )]) } +fn resolve_goonfi_market_address(address: Option<&str>) -> Result { + match address.map(str::trim) { + None | Some("") => Ok(GOONFI_DEFAULT_MARKET), + Some(value) => Pubkey::from_str(value) + .map_err(|error| format!("Invalid GoonFi market pubkey: {error}")), + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct GetTokenAddressParams { #[schemars(description = "The token symbol to look up (e.g., 'USDC', 'SOL', 'JUP')")] @@ -455,13 +463,7 @@ impl Surfpool { address: Option<&str>, surfnet_port: Option, ) -> Result { - let market_address = match address.map(str::trim) { - None | Some("") => { - surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET - } - Some(value) => Pubkey::from_str(value) - .map_err(|error| format!("Invalid GoonFi market pubkey: {error}"))?, - }; + let market_address = resolve_goonfi_market_address(address)?; let market_account = self .fetch_surfnet_accounts(surfnet_port, &[market_address]) .await? @@ -491,6 +493,44 @@ impl Surfpool { .map_err(|error| error.to_string()) } + async fn build_goonfi_liquidity( + &self, + params: &CreateGoonfiLiquidityScenarioParams, + ) -> Result { + let market_address = resolve_goonfi_market_address(params.market.as_deref())?; + let market_account = self + .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; + // Vaults and oracle are read from the market's own pointers, never taken from the caller. + let [base_vault, quote_vault] = + vault_addresses(&market_account).map_err(|error| error.to_string())?; + let oracle = + GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; + let referenced = self + .fetch_surfnet_accounts(params.surfnet_port, &[base_vault, quote_vault, oracle]) + .await?; + let account = |index: usize, name: &str| { + referenced[index] + .as_ref() + .ok_or_else(|| format!("GoonFi {name} account was not found")) + }; + let preparation = build_goonfi_liquidity_scenario( + market_address, + &market_account, + (base_vault, account(0, "base vault")?), + (quote_vault, account(1, "quote vault")?), + (oracle, account(2, "oracle")?), + params.base_remaining_bps.unwrap_or(0), + params.quote_remaining_bps.unwrap_or(0), + ) + .map_err(|error| error.to_string())?; + Ok(preparation.scenario) + } + async fn stage_scenario(&self, scenario: Scenario) -> Result { let endpoint = format!( "http://127.0.0.1:{}/v1/scenarios", @@ -1154,78 +1194,10 @@ impl Surfpool { &self, Parameters(params): Parameters, ) -> Result { - let market_address = match params.market.as_deref().map(str::trim) { - None | Some("") => { - surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET - } - Some(value) => match Pubkey::from_str(value) { - Ok(market) => market, - Err(error) => { - return Ok(scenario_tool_error(format!( - "Invalid GoonFi market pubkey: {error}" - ))); - } - }, - }; - let market_account = match self - .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) - .await - { - Ok(mut accounts) => match accounts.remove(0) { - Some(account) => account, - None => { - return Ok(scenario_tool_error(format!( - "GoonFi market account {market_address} was not found" - ))); - } - }, - Err(error) => return Ok(scenario_tool_error(error)), - }; - // Vaults and oracle are read from the market's own pointers, never taken from the caller. - let [base_vault, quote_vault] = match vault_addresses(&market_account) { - Ok(addresses) => addresses, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; - let oracle = match GoonfiMarket::oracle_address(&market_account) { - Ok(oracle) => oracle, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; - let referenced = match self - .fetch_surfnet_accounts(params.surfnet_port, &[base_vault, quote_vault, oracle]) - .await - { - Ok(accounts) => accounts, - Err(error) => return Ok(scenario_tool_error(error)), - }; - let account = |index: usize, name: &str| { - referenced[index] - .as_ref() - .ok_or_else(|| format!("GoonFi {name} account was not found")) - }; - let (base_account, quote_account, oracle_account) = match ( - account(0, "base vault"), - account(1, "quote vault"), - account(2, "oracle"), - ) { - (Ok(base), Ok(quote), Ok(oracle)) => (base, quote, oracle), - (Err(error), ..) | (_, Err(error), _) | (.., Err(error)) => { - return Ok(scenario_tool_error(error)); - } - }; - let preparation = match build_goonfi_liquidity_scenario( - market_address, - &market_account, - (base_vault, base_account), - (quote_vault, quote_account), - (oracle, oracle_account), - params.base_remaining_bps.unwrap_or(0), - params.quote_remaining_bps.unwrap_or(0), - ) { - Ok(preparation) => preparation, - Err(error) => return Ok(scenario_tool_error(error.to_string())), - }; - - self.stage_scenario(preparation.scenario).await + match self.build_goonfi_liquidity(¶ms).await { + Ok(scenario) => self.stage_scenario(scenario).await, + Err(error) => Ok(scenario_tool_error(error)), + } } #[tool( @@ -1543,9 +1515,9 @@ mod tests { } #[tokio::test] - async fn goonfi_price_rejects_a_bad_market_before_any_rpc() { + async fn goonfi_tools_reject_a_bad_market_before_any_rpc() { let surfpool = Surfpool::new(); - let result = surfpool + let price = surfpool .create_goonfi_price_scenario(Parameters(CreateGoonfiPriceScenarioParams { surfnet_port: None, market: Some("not-a-pubkey".to_string()), @@ -1553,17 +1525,7 @@ mod tests { })) .await .expect("the tool reports input errors in its payload, not as a protocol error"); - let text = format!("{:?}", result.content); - assert!( - text.contains("Invalid GoonFi market pubkey"), - "unexpected payload: {text}" - ); - } - - #[tokio::test] - async fn goonfi_liquidity_rejects_a_bad_market_before_any_rpc() { - let surfpool = Surfpool::new(); - let result = surfpool + let liquidity = surfpool .create_goonfi_liquidity_scenario(Parameters(CreateGoonfiLiquidityScenarioParams { surfnet_port: None, market: Some("not-a-pubkey".to_string()), @@ -1572,11 +1534,15 @@ mod tests { })) .await .expect("the tool reports input errors in its payload, not as a protocol error"); - let text = format!("{:?}", result.content); - assert!( - text.contains("Invalid GoonFi market pubkey"), - "unexpected payload: {text}" - ); + for result in [price, liquidity] { + let payload = json_of(&result); + assert!( + payload["error"] + .as_str() + .is_some_and(|error| error.contains("Invalid GoonFi market pubkey")), + "unexpected payload: {payload}" + ); + } } #[tokio::test] diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index d4c8e293b..955852a3f 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1725,10 +1725,6 @@ mod tests { width: 8 } ); - assert_eq!( - parsed.encode(&json!(null), 500).unwrap(), - 480u64.to_le_bytes().to_vec() - ); let narrow: RawEncoding = serde_json::from_value(json!({"slot": {"lead": 0, "width": 4}})).unwrap(); From 475b48e4c994d55e27b941acec8d196316b11836 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 17 Sep 2026 11:39:14 +0300 Subject: [PATCH 14/20] refactor(scenarios): remove override persistence from Kamino --- crates/core/src/scenarios/README.md | 11 +- .../src/scenarios/protocols/kamino/README.md | 11 +- .../protocols/kamino/scope/v1/overrides.yaml | 4 - .../protocols/kamino/v1/overrides.yaml | 6 - crates/core/src/surfnet/svm.rs | 561 +----------------- .../kit/generated/OverrideInstance.ts | 4 - crates/types/src/rpc_endpoints.json | 1 - crates/types/src/scenarios.rs | 13 - 8 files changed, 9 insertions(+), 602 deletions(-) diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 0db4e9745..24292a78d 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -35,15 +35,8 @@ whole struct or array) also works, but it must be **complete** - every field of padding included - because the account is re-encoded with Borsh. An out-of-range index or a non-numeric segment on an array is a hard error, never a silent write elsewhere. -By default an override is applied once, and the resulting account state naturally remains in later -slots. Leave `"persist"` unset unless a known later transaction, account fetch, or override will -replace a scenario-controlled input that must stay pinned. With `"persist": true`, Surfpool -re-applies the values at the beginning of every following slot. It does not continuously protect -them from writes within a slot. Never pin state whose transaction-driven evolution the scenario is -measuring: re-applying a balance, position, or accumulator undoes those writes at the next slot, so -a pool could refill itself after every swap. Only one entry is queued per override, so it is never -applied twice to one slot, and `fetchBeforeUse` applies to the first slot only - later slots re-pin -the fields without re-fetching the account. +An override is applied once. Its resulting account state remains in later slots until a transaction +or another override writes that account again. ### Kamino integration tests diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md index 71678503e..5a8388179 100644 --- a/crates/core/src/scenarios/protocols/kamino/README.md +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -7,9 +7,9 @@ This is a how-to. For how scenarios work in general see the [scenarios README](. Every field's own purpose and units are on the template itself, visible in Studio and via `get_override_templates`. -## Two rules that decide whether an override sticks +## Override inputs, not results -**1. Override inputs, not results.** Kamino stores settings someone chose (`liquidation_threshold_pct`) +Kamino stores settings someone chose (`liquidation_threshold_pct`) and values it computed from them (`market_price_sf`, the Obligation's `*_value_sf`). Before a liquidation it runs `refresh_reserve` and `refresh_obligation`, which recompute every computed value. So overriding a computed value is discarded moments later. @@ -19,12 +19,6 @@ So overriding a computed value is discarded moments later. | A price | `kamino-scope-price` | `liquidity.market_price_sf` | | Position health | `kamino-reserve-config` → `liquidation_threshold_pct` | `kamino-obligation-health` | -**2. Do not persist by default.** An override applied once already remains in later slots. Add -`"persist": true` only when a known later update or fetch would overwrite a scenario-controlled -input that must stay pinned, such as an oracle price while its updater continues running. Never pin -state whose changes you are testing (reserve liquidity, obligations, farm rewards, or vault -balances): re-applying it undoes those changes at the beginning of the next slot. - ## Number formats | You'll see | It means | Example | @@ -202,7 +196,6 @@ kamino-swap-order | `exceeds what a JSON number can hold exactly` | Pass large `u128`/`i128` values as decimal strings, e.g. `"1152921504606846976000"`. Plain JSON numbers are fine below 2^53 | | `Account with discriminator ... not found in IDL` | The account is not Anchor-based (e.g. Raydium AMM v4). It cannot be overridden through the IDL path | | `Failed to resolve account address` | The `pubkey` is not valid base58 | -| A later update replaced a scenario-controlled input | Add `"persist": true` only when that input must stay pinned; do not use it merely because the scenario spans multiple slots | | A value the program recomputes will not stay put | Pin the input it reads instead: Scope price over a Reserve's cached price, `liquidation_threshold_pct` over the Obligation's health fields | --- diff --git a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml index 18ea4ad65..94c801e97 100644 --- a/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/scope/v1/overrides.yaml @@ -45,10 +45,6 @@ templates: [210, 3] means price = prices[210] * prices[3] 4. Set price.value = usd_price * 10^exp, keeping exp as you found it 5. Set last_updated_slot and unix_timestamp to now, or Kamino rejects the price as stale - 6. Leave persist unset unless a known later Scope update or full-account fetch would restore - the real price. If that happens and the simulated price must remain pinned, set persist: true; - Surfpool will restore it at the beginning of each following slot - SCOPE INDICES (verified 2026-08-06, do not guess these): - 3t4JZcueEzTbVP6kLxXrL3VpWx45jDer4eqysweBchNH (Main Market): SOL=3, USDC=13, PYUSD=148, cbBTC=175, JitoSOL=[210,3] diff --git a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml index c4abd78ed..85662a1f0 100644 --- a/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml +++ b/crates/core/src/scenarios/protocols/kamino/v1/overrides.yaml @@ -382,9 +382,6 @@ templates: EXAMPLE - "liquidate SOL collateral above 50% LTV": config.liquidation_threshold_pct: 50 - Leave persist unset unless a known later write or fetch would replace config.* and the - scenario must keep that configuration pinned. If persistence is needed, override config.* - fields only; liquidity.* and last_update.* are transaction-owned state. - id: kamino-reserve-main-usdc name: Override USDC Reserve (Main Market) description: Override the USDC reserve of Kamino's Main Market @@ -417,9 +414,6 @@ templates: EXAMPLE - "USDC depegs to $0.90": use kamino-scope-price with prices.13.price.value: 90000000 and prices.13.price.exp: 8 - Leave persist unset unless a known later write or fetch would replace config.* and the - scenario must keep that configuration pinned. If persistence is needed, override config.* - fields only; liquidity.* and last_update.* are transaction-owned state. # ========================================== # Obligation # ========================================== diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 8297d133e..d9ba3fb72 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -660,30 +660,6 @@ fn synthetic_blockhash_for_slot(slot: Slot, genesis_slot: Slot) -> SyntheticBloc SyntheticBlockhash::new(u64::MAX - (genesis_slot - slot - 1)) } -/// What one `fetch_before_use` attempt settled. Decides whether a persisted override keeps -/// asking on later slots, which it must while another attempt could still change the answer. -enum FetchOutcome { - /// No further remote fetch is needed: none was requested, the account was already settled - /// earlier in this slot, or the requested fetch succeeded. - Retired, - /// There is no remote to ask. Only a local account can satisfy the request. - NoRemote, - /// The remote has no such account. It may be created later. - NotOnRemote, - /// No answer was obtained. Another attempt may get one. - Unanswered, -} - -impl FetchOutcome { - fn is_fetch_retired(&self, has_local_account: bool) -> bool { - match self { - Self::Retired => true, - Self::Unanswered => false, - Self::NoRemote | Self::NotOnRemote => has_local_account, - } - } -} - impl SurfnetSvm { pub(crate) fn mark_transaction_pending(&mut self, signature: Signature) { *self @@ -2968,10 +2944,6 @@ impl SurfnetSvm { override_instance.id, account_pubkey, override_instance.label ); - // Defaults to Retired: nothing was asked for, the account was already forked by an - // earlier override this slot, or there is no remote to ask. - let mut fetch_outcome = FetchOutcome::Retired; - // Fetch fresh account data from remote if requested if override_instance.fetch_before_use && !settled_this_slot.contains(&account_pubkey) { if let Some((client, _)) = remote_ctx { @@ -3002,7 +2974,6 @@ impl SurfnetSvm { )), Ok(GetAccountResult::None(_)) => { debug!("Account {} not found on remote", account_pubkey); - fetch_outcome = FetchOutcome::NotOnRemote; None } Err(e) => { @@ -3010,7 +2981,6 @@ impl SurfnetSvm { "Failed to fetch account {} from remote: {}", account_pubkey, e ); - fetch_outcome = FetchOutcome::Unanswered; None } }; @@ -3053,7 +3023,6 @@ impl SurfnetSvm { "Failed to set account {} from remote: {}", account_pubkey, e ); - fetch_outcome = FetchOutcome::Unanswered; } else { settled_this_slot.insert(account_pubkey); } @@ -3063,7 +3032,6 @@ impl SurfnetSvm { "fetch_before_use enabled but no remote client available for override {}", override_instance.id ); - fetch_outcome = FetchOutcome::NoRemote; } } @@ -3075,20 +3043,6 @@ impl SurfnetSvm { } }; - // The request is only retired when another attempt could no longer change anything. - let is_fetch_retired = fetch_outcome.is_fetch_retired(existing_account.is_some()); - - if override_instance.persist { - let mut requeued = override_instance.clone(); - if requeued.fetch_before_use && is_fetch_retired { - requeued.fetch_before_use = false; - } - if let Err(e) = self.reschedule_override_for_next_slot(requeued, target_slot) { - restore_unprocessed(self, index); - return Err(e); - } - } - // Apply the override values to the account data if !override_instance.values.is_empty() { // Filter out values that are only used for PDA derivation (not account data) @@ -3238,37 +3192,6 @@ impl SurfnetSvm { Ok(()) } - /// Re-queues `instance` for the slot after `target_slot`, replacing any copy of itself - /// already queued there. One entry per id, so an override cannot be applied twice to one slot. - fn reschedule_override_for_next_slot( - &mut self, - instance: OverrideInstance, - target_slot: Slot, - ) -> SurfpoolResult<()> { - let next_slot = target_slot.checked_add(1).ok_or_else(|| { - SurfpoolError::internal(format!( - "Override {} cannot persist past slot {}: there is no next slot", - instance.id, target_slot - )) - })?; - let mut next = self - .scheduled_overrides - .get(&next_slot)? - .unwrap_or_default(); - - if let Some(existing) = next.iter_mut().find(|queued| { - queued.id == instance.id - && queued.account == instance.account - && queued.template_id == instance.template_id - }) { - *existing = instance; - } else { - next.push(instance); - } - self.scheduled_overrides.store(next_slot, next)?; - Ok(()) - } - /// Forges account data by applying overrides to existing account data /// /// This function: @@ -7643,27 +7566,12 @@ mod tests { assert_eq!(restored_account.lamports, 1_000_000); } - #[test] - fn fetch_outcome_retires_only_when_no_later_fetch_can_help() { - for has_local_account in [false, true] { - assert!(FetchOutcome::Retired.is_fetch_retired(has_local_account)); - assert!(!FetchOutcome::Unanswered.is_fetch_retired(has_local_account)); - } - - for outcome in [FetchOutcome::NoRemote, FetchOutcome::NotOnRemote] { - assert!(!outcome.is_fetch_retired(false)); - assert!(outcome.is_fetch_retired(true)); - } - } - /// `Obligation.unhealthy_borrow_value_sf` (u128), counting the discriminator. const UNHEALTHY_OFFSET: usize = 2256; /// A zeroed Kamino `Obligation` owned by klend. `SurfnetSvm::default()` already registers /// the bundled template IDLs, so klend's is resolvable by owner program. - fn scheduled_persist_fixture( - persist: bool, - ) -> (SurfnetSvm, Pubkey, surfpool_types::OverrideInstance) { + fn scheduled_override_fixture() -> (SurfnetSvm, Pubkey, surfpool_types::OverrideInstance) { let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); let klend = Pubkey::from_str_const("KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD"); @@ -7694,7 +7602,7 @@ mod tests { ) .expect("set obligation account"); - let mut instance = surfpool_types::OverrideInstance::new( + let instance = surfpool_types::OverrideInstance::new( "kamino-obligation-health".to_string(), 0, surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), @@ -7703,286 +7611,12 @@ mod tests { "unhealthy_borrow_value_sf".to_string(), serde_json::json!(1_234u64), )])); - instance.persist = persist; - (surfnet_svm, account_pubkey, instance) } #[tokio::test] - async fn test_persisted_override_is_rescheduled_for_the_next_slot() { - const SLOT: u64 = 500; - - let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); - let instance_id = instance.id.clone(); - svm.scheduled_overrides - .store(SLOT, vec![instance]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&None, SLOT) - .await - .expect("materialize"); - - let account = svm - .inner - .get_account(&account_pubkey) - .expect("get_account") - .expect("account present"); - let unhealthy = u128::from_le_bytes( - account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] - .try_into() - .expect("16 bytes"), - ); - assert_eq!(unhealthy, 1_234, "override should have been applied"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot should have queued overrides"); - assert_eq!( - next.len(), - 1, - "exactly one override queued for the next slot" - ); - assert_eq!(next[0].id, instance_id); - assert!(next[0].persist, "persist flag must survive rescheduling"); - - assert!( - svm.scheduled_overrides - .get(&SLOT) - .expect("storage read") - .is_none(), - "materialized slot should be drained" - ); - } - - #[tokio::test] - async fn test_persisted_override_stops_refetching_once_the_account_is_forked() { - const SLOT: u64 = 500; - - let (mut svm, _account_pubkey, mut instance) = scheduled_persist_fixture(true); - instance.fetch_before_use = true; - svm.scheduled_overrides - .store(SLOT, vec![instance]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&None, SLOT) - .await - .expect("materialize"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot should have queued overrides"); - assert_eq!(next.len(), 1, "one entry per override id"); - assert!(next[0].persist, "persist must survive rescheduling"); - assert!( - !next[0].fetch_before_use, - "the account is forked, so later slots must not re-fetch it and discard local writes" - ); - } - - /// An override that writes no account fields still forks the account, so it must stop fetching too. - #[tokio::test] - async fn test_persisted_override_that_writes_no_fields_stops_refetching() { - const SLOT: u64 = 500; - - let (mut svm, account_pubkey, _instance) = scheduled_persist_fixture(true); - - // Values consumed entirely by PDA derivation, so `account_values` filters down to empty. - let seed_only = surfpool_types::OverrideInstance::new( - "kamino-obligation-health".to_string(), - 0, - surfpool_types::AccountAddress::Pda { - program_id: "KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD".to_string(), - seeds: vec![surfpool_types::PdaSeed::PropertyRef("market".to_string())], - }, - ) - .with_values(HashMap::from([( - "market".to_string(), - serde_json::json!(account_pubkey.to_string()), - )])); - - // Point the derived address at a real forked account so presence is what is being tested. - let derived = seed_only - .account - .resolve(Some(&seed_only.values)) - .expect("derive pda"); - let forked = svm - .inner - .get_account(&account_pubkey) - .expect("get_account") - .expect("fixture account present"); - svm.inner - .set_account(derived, forked) - .expect("set derived account"); - - let mut no_values = surfpool_types::OverrideInstance::new( - "kamino-obligation-noop".to_string(), - 0, - surfpool_types::AccountAddress::Pubkey(account_pubkey.to_string()), - ); - no_values.persist = true; - no_values.fetch_before_use = true; - - let mut seed_only = seed_only; - seed_only.persist = true; - seed_only.fetch_before_use = true; - - svm.scheduled_overrides - .store(SLOT, vec![seed_only, no_values]) - .expect("schedule overrides"); - - svm.materialize_overrides_for_slot(&None, SLOT) - .await - .expect("materialize"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot should have queued overrides"); - assert_eq!(next.len(), 2, "both overrides re-armed, one entry each"); - for queued in &next { - assert!(queued.persist, "persist must survive rescheduling"); - assert!( - !queued.fetch_before_use, - "override {} forked its account, so later slots must not re-fetch it", - queued.id - ); - } - } - - /// The remote having no such account is only an answer about this slot - accounts get created - /// later. While there is nothing local to work on, a persisted override must keep asking, or - /// it stays inert for the rest of the run. - #[tokio::test] - async fn test_persisted_override_retries_while_the_account_is_not_on_remote() { - const SLOT: u64 = 500; - const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; - - let url = canned_rpc(NULL_ACCOUNT).await; - let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); - - let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); - - // An address the SVM has never seen, so there is no local account to fall back on. - let mut absent = surfpool_types::OverrideInstance::new( - "kamino-obligation-health".to_string(), - 0, - surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), - ) - .with_values(HashMap::from([( - "unhealthy_borrow_value_sf".to_string(), - serde_json::json!(1_234u64), - )])); - absent.persist = true; - absent.fetch_before_use = true; - - svm.scheduled_overrides - .store(SLOT, vec![absent]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&Some(remote), SLOT) - .await - .expect("materialize"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot should have queued overrides"); - assert_eq!(next.len(), 1, "one entry per override id"); - assert!( - next[0].fetch_before_use, - "the account may appear later, so the next slot must keep asking for it" - ); - } - - /// The mirror case: the remote has nothing but a local account already exists, so the override - /// can work. Asking again would only risk overwriting that local account once the address is - /// populated upstream. - #[tokio::test] - async fn test_persisted_override_stops_asking_when_only_a_local_account_exists() { - const SLOT: u64 = 500; - const NULL_ACCOUNT: &str = r#"{"context":{"apiVersion":"2.1.0","slot":1},"value":null}"#; - - let url = canned_rpc(NULL_ACCOUNT).await; - let remote = (SurfnetRemoteClient::new(url), CommitmentConfig::confirmed()); - - let (mut svm, _account_pubkey, mut instance) = scheduled_persist_fixture(true); - instance.fetch_before_use = true; - svm.scheduled_overrides - .store(SLOT, vec![instance]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&Some(remote), SLOT) - .await - .expect("materialize"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot should have queued overrides"); - assert_eq!(next.len(), 1, "one entry per override id"); - assert!( - !next[0].fetch_before_use, - "the local account is usable, so later fetches must not overwrite it" - ); - } - - /// With no remote client there is nothing to fetch from, but the request is still unmet while - /// the account is absent. `materialize_overrides_for_slot` is public, so a caller can pass a - /// client on a later slot - retiring the flag here would permanently disable that. - #[tokio::test] - async fn test_persisted_override_keeps_asking_when_absent_and_no_remote() { - const SLOT: u64 = 500; - - let (mut svm, _account_pubkey, _instance) = scheduled_persist_fixture(true); - - let mut absent = surfpool_types::OverrideInstance::new( - "kamino-obligation-health".to_string(), - 0, - surfpool_types::AccountAddress::Pubkey(Pubkey::new_unique().to_string()), - ) - .with_values(HashMap::from([( - "unhealthy_borrow_value_sf".to_string(), - serde_json::json!(1_234u64), - )])); - absent.persist = true; - absent.fetch_before_use = true; - - svm.scheduled_overrides - .store(SLOT, vec![absent]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&None, SLOT) - .await - .expect("materialize"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot should have queued overrides"); - assert_eq!(next.len(), 1, "one entry per override id"); - assert!( - next[0].fetch_before_use, - "the request is still unmet, so it must not be retired" - ); - } - - #[tokio::test] - async fn test_slot_overflow_is_an_error_not_a_wrap() { - let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); - - assert!( - svm.reschedule_override_for_next_slot(instance, u64::MAX) - .is_err(), - "there is no slot after u64::MAX" - ); + async fn test_scenario_relative_slot_overflow_is_an_error_not_a_wrap() { + let (mut svm, account_pubkey, _instance) = scheduled_override_fixture(); let mut far = surfpool_types::OverrideInstance::new( "kamino-obligation-health".to_string(), @@ -8004,42 +7638,6 @@ mod tests { ); } - /// A transient RPC failure must not be mistaken for a satisfied fetch. The account already - /// being present locally is not enough - the override asked for fresh data and did not get it, - /// so with `persist` the flag has to survive or it pins stale data for the rest of the run. - #[tokio::test] - async fn test_persisted_override_retries_after_a_failed_fetch() { - const SLOT: u64 = 500; - - // Unroutable port: the fetch fails without touching the network. - let unreachable = ( - SurfnetRemoteClient::new("http://127.0.0.1:1"), - CommitmentConfig::confirmed(), - ); - - let (mut svm, _account_pubkey, mut instance) = scheduled_persist_fixture(true); - instance.fetch_before_use = true; - svm.scheduled_overrides - .store(SLOT, vec![instance]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&Some(unreachable), SLOT) - .await - .expect("materialize"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot should have queued overrides"); - assert_eq!(next.len(), 1, "one entry per override id"); - assert!(next[0].persist, "persist must survive rescheduling"); - assert!( - next[0].fetch_before_use, - "the fetch failed, so the next slot must retry it instead of pinning stale data" - ); - } - /// Guards the ordering invariant only. The re-fetch that used to clobber the first override /// needs a remote client, so `remote_ctx: &None` cannot reproduce it here - that path is /// covered against a live fork. @@ -8049,7 +7647,7 @@ mod tests { // immediately precedes unhealthy_borrow_value_sf in the Obligation layout const ALLOWED_OFFSET: usize = UNHEALTHY_OFFSET - 16; - let (mut svm, account_pubkey, first) = scheduled_persist_fixture(false); + let (mut svm, account_pubkey, first) = scheduled_override_fixture(); let mut first = first; first.fetch_before_use = true; @@ -8091,153 +7689,4 @@ mod tests { "the second override must apply" ); } - - /// Two persistent overrides that share a caller-supplied id but target different accounts must both survive re-arming. - #[tokio::test] - async fn test_reschedule_keeps_overrides_sharing_an_id_across_accounts() { - const SLOT: u64 = 500; - let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); - - let first_account = Pubkey::new_unique(); - let second_account = Pubkey::new_unique(); - - let mut first = surfpool_types::OverrideInstance::new( - "kamino-obligation-health".to_string(), - 0, - surfpool_types::AccountAddress::Pubkey(first_account.to_string()), - ); - // The collision this guards against: a hand-written scenario reusing a plain id. - first.id = "ov-1".to_string(); - first.persist = true; - - let mut second = first.clone(); - second.account = surfpool_types::AccountAddress::Pubkey(second_account.to_string()); - - surfnet_svm - .reschedule_override_for_next_slot(first.clone(), SLOT) - .expect("reschedule"); - surfnet_svm - .reschedule_override_for_next_slot(second, SLOT) - .expect("reschedule"); - - let queued = surfnet_svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("read scheduled overrides") - .expect("overrides queued for the next slot"); - assert_eq!( - queued.len(), - 2, - "two overrides on different accounts share the id 'ov-1'; keying only on the id drops \ - one of them, so a scenario silently stops being applied" - ); - - surfnet_svm - .reschedule_override_for_next_slot(first, SLOT) - .expect("reschedule"); - let queued = surfnet_svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("read scheduled overrides") - .expect("overrides queued for the next slot"); - assert_eq!( - queued.len(), - 2, - "re-arming an override must replace its own queued copy, not append a duplicate" - ); - } - - #[tokio::test] - async fn test_non_persisted_override_is_not_rescheduled() { - const SLOT: u64 = 500; - - let (mut svm, _account_pubkey, instance) = scheduled_persist_fixture(false); - svm.scheduled_overrides - .store(SLOT, vec![instance]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&None, SLOT) - .await - .expect("materialize"); - - assert!( - svm.scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .is_none(), - "a one-shot override must not be rescheduled" - ); - } - - #[tokio::test] - async fn test_persisted_override_survives_a_run_of_slots() { - const FIRST_SLOT: u64 = 900; - const SLOTS: u64 = 5; - - let (mut svm, account_pubkey, instance) = scheduled_persist_fixture(true); - svm.scheduled_overrides - .store(FIRST_SLOT, vec![instance]) - .expect("schedule override"); - - for slot in FIRST_SLOT..FIRST_SLOT + SLOTS { - // Clobber the field, the way `refresh_obligation` would. - let mut account = svm - .inner - .get_account(&account_pubkey) - .expect("get_account") - .expect("account present"); - account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] - .copy_from_slice(&0u128.to_le_bytes()); - svm.inner - .set_account(account_pubkey, account) - .expect("clobber account"); - - svm.materialize_overrides_for_slot(&None, slot) - .await - .expect("materialize"); - - let account = svm - .inner - .get_account(&account_pubkey) - .expect("get_account") - .expect("account present"); - let unhealthy = u128::from_le_bytes( - account.data[UNHEALTHY_OFFSET..UNHEALTHY_OFFSET + 16] - .try_into() - .expect("16 bytes"), - ); - assert_eq!( - unhealthy, 1_234, - "persisted override should be re-applied on slot {slot} after being clobbered" - ); - } - } - - #[tokio::test] - async fn test_persisted_override_does_not_duplicate_itself() { - const SLOT: u64 = 700; - - let (mut svm, _account_pubkey, instance) = scheduled_persist_fixture(true); - svm.scheduled_overrides - .store(SLOT + 1, vec![instance.clone()]) - .expect("pre-queue next slot"); - svm.scheduled_overrides - .store(SLOT, vec![instance]) - .expect("schedule override"); - - svm.materialize_overrides_for_slot(&None, SLOT) - .await - .expect("materialize"); - - let next = svm - .scheduled_overrides - .get(&(SLOT + 1)) - .expect("storage read") - .expect("next slot queue"); - assert_eq!( - next.len(), - 1, - "override must not be queued twice for one slot" - ); - } } diff --git a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts index 80a87f241..348ea2ae5 100644 --- a/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts +++ b/crates/sdk-node/surfpool-sdk/kit/generated/OverrideInstance.ts @@ -35,10 +35,6 @@ enabled: boolean, * Whether to fetch fresh account data just before transaction execution */ fetchBeforeUse?: boolean, -/** - * Whether to re-apply this override on every subsequent slot, rather than only once - */ -persist?: boolean, /** * Account address to override - use pubkey for known addresses or pda for derived addresses */ diff --git a/crates/types/src/rpc_endpoints.json b/crates/types/src/rpc_endpoints.json index 740e1e8a3..d863d035a 100644 --- a/crates/types/src/rpc_endpoints.json +++ b/crates/types/src/rpc_endpoints.json @@ -861,7 +861,6 @@ "label": "Option (An optional label for this override instance)", "enabled": "bool (Indicates whether this override instance is enabled)", "fetchBeforeUse": "bool (Indicates whether to fetch the latest on-chain account data before applying overrides)", - "persist": "bool (Optional, defaults to false. If true, re-applies this override on every following slot instead of only one, which is needed when something else writes the account in between. Use it only for values no transaction writes - an oracle price, a disabled switch, a risk parameter - never for state the transactions under test mutate: re-applying reverts their writes at the start of the next slot, so a pool would refill itself after every swap)", "account": "AccountAddress (The account this override targets, as {\"pubkey\": \"\"} or {\"pda\": {\"programId\": \"\", \"seeds\": [ ... ]}})" } } diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index a04ca80a1..358bba81b 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -500,13 +500,6 @@ pub struct OverrideInstance { #[serde(default)] #[cfg_attr(feature = "ts-bindings", ts(as = "Option", optional))] pub fetch_before_use: bool, - /// Whether to re-apply this override on every subsequent slot, rather than only once - #[schemars( - description = "If true, re-applies this override every following slot. Use only for values no transaction writes: it reverts transaction writes to the same fields." - )] - #[serde(default)] - #[cfg_attr(feature = "ts-bindings", ts(as = "Option", optional))] - pub persist: bool, /// Account address to override - use pubkey for known addresses or pda for derived addresses #[schemars( description = "Account address: either {\"pubkey\": \"base58_address\"} or {\"pda\": {\"programId\": \"...\", \"seeds\": [...]}}" @@ -524,7 +517,6 @@ impl OverrideInstance { label: None, enabled: true, fetch_before_use: false, - persist: false, account, } } @@ -538,11 +530,6 @@ impl OverrideInstance { self.label = Some(label); self } - - pub fn with_persist(mut self, persist: bool) -> Self { - self.persist = persist; - self - } } /// A scenario containing a timeline of overrides From a79c19dd1f5d6881ed429e45b0da3b02147a7978 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 17 Sep 2026 16:24:27 +0300 Subject: [PATCH 15/20] fix(goonfi): keep the catalog when a reference batch fails --- .../src/scenarios/protocols/goonfi/v1/markets.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index 0dc1f12f2..ec98015aa 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -151,9 +151,18 @@ pub async fn discover_goonfi_markets( addresses.dedup(); let mut references = HashMap::new(); for batch in addresses.chunks(100) { - let fetched = client + // A datasource failure disqualifies only the markets whose references were in this batch, + // the same way a reference the fork cannot serve does. + let fetched = match client .get_multiple_accounts(batch, CommitmentConfig::confirmed()) - .await?; + .await + { + Ok(fetched) => fetched, + Err(error) => { + warn!("Skipping {} GoonFi references: {error}", batch.len()); + continue; + } + }; for (address, account) in batch.iter().zip(fetched) { // A reference the fork cannot serve disqualifies only the markets pointing at it, // which `resolve_market` reports below. From 495b30c97dbe3ced9e24b2f5265453a353ae3330 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Fri, 18 Sep 2026 08:45:08 +0300 Subject: [PATCH 16/20] docs(goonfi): say that the freshness override is written once --- crates/core/src/scenarios/protocols/goonfi/README.md | 6 +++--- crates/core/src/scenarios/protocols/goonfi/v1/mod.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md index 81cfc389b..4fa0a236a 100644 --- a/crates/core/src/scenarios/protocols/goonfi/README.md +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -45,9 +45,9 @@ The price builder does not set `fetchBeforeUse`: the accounts read at creation r local edits, and only the specified fields are changed. Freshness writes the materialization slot once, like every other override: a forked oracle is not republished, so the stamp only has to be recent enough for the scenario's own slots. -A scenario that runs past the staleness window schedules another refresh at a later -slot. These settings do not establish transactional atomicity across all overrides in -a scenario. +A scenario extended past the staleness window needs another freshness override of +its own at a later slot. These settings do not establish transactional atomicity +across all overrides in a scenario. ## Composing other prepared states diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index fcb967265..dafaeef34 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -41,8 +41,8 @@ fn read_pubkey(data: &[u8], offset: usize) -> Option { } /// Applied once, at the scenario's own slot: nothing on a fork republishes the quote, and nothing -/// overwrites it either. A scenario that spans enough slots to age past the oracle's window -/// refreshes it again at a later slot. +/// overwrites it either. Play pauses the clock there, so a swap in that slot reads a fresh quote. +/// Running past the oracle's window takes a second freshness override; the builder adds only one. fn freshness_override(template_id: String, oracle: &Pubkey) -> OverrideInstance { OverrideInstance::new( template_id, From e6d974e9f26922674718a451e432510afed020b0 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 24 Sep 2026 09:31:42 +0300 Subject: [PATCH 17/20] feat(scenarios): extend raw encodings with u8_strided, 4-byte slots and xor_mask --- crates/core/src/scenarios/README.md | 8 + crates/core/src/scenarios/registry.rs | 32 +++ crates/mcp/src/surfpool/mod.rs | 6 +- crates/types/src/scenarios.rs | 309 ++++++++++++++++++++++++-- 4 files changed, 339 insertions(+), 16 deletions(-) diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index f1ab7e50e..212189039 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -73,6 +73,14 @@ caller's responsibility. This permits compatible accounts with additional traili that remain valid across program upgrades. Large integers that exceed `u64` should be supplied as decimal strings so JSON parsing cannot lose precision. +A few encodings cover layouts that plain integers can't: + +- `u8_strided: { count, stride }` writes one byte `count` times, `stride` bytes apart, for a flag + repeated across fixed-size records. +- `slot: { lead, width }` writes the target slot plus `lead`. `width` is 4 or 8 and defaults to 8. +- `xor_mask: ` on a property XORs its 8-byte encoded word with the key before writing, for + programs that store masked values. Narrower encodings are rejected at load. + Each collection uses exactly one write model: an IDL-backed collection cannot set `raw_layout: true`, and an IDL-less collection must set it. Raw-layout collections are currently compiled into Surfpool's built-in template registry; there is no runtime raw-layout registration diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 0ec954cc5..6e62bfc01 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -711,6 +711,38 @@ templates: "missing an encoding", ); + rejected( + r#" +protocol: Broken +version: v1 +raw_layout: true +templates: + - id: narrow-mask + name: Narrow mask + description: Invalid + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: + - { path: value, offset: 8, encoding: u32, xor_mask: 1 } +"#, + "8-byte", + ); + + rejected( + r#" +protocol: Broken +version: v1 +raw_layout: true +templates: + - id: odd-slot + name: Odd slot + description: Invalid + address: { type: pubkey, value: "11111111111111111111111111111111" } + properties: + - { path: value, offset: 8, encoding: { slot: { lead: 0, width: 2 } } } +"#, + "must be 4 or 8", + ); + let mut registry = TemplateRegistry::default(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { registry.load_raw_layout_overrides( diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 241cb69a6..e79af19fc 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -1542,8 +1542,12 @@ mod tests { .expect("template exists") .clone(); template.raw_layout = true; + template.properties[0].xor_mask = Some(7); - assert_eq!(compact_template_json(&template)["rawLayout"], true); + let json = compact_template_json(&template); + assert_eq!(json["rawLayout"], true); + assert_eq!(json["properties"][0]["xor_mask"], 7); + assert!(json["properties"][1].get("xor_mask").is_none()); } #[test] diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index a97e07a4b..afe2dc136 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -283,6 +283,10 @@ pub struct Property { /// Raw-layout only: how this field's bytes are produced. #[serde(default, skip_serializing_if = "Option::is_none")] pub encoding: Option, + /// Raw-layout only: XOR key applied to the encoded bytes before writing. Only 8-byte encodings + /// can be masked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xor_mask: Option, } impl Property { @@ -296,6 +300,7 @@ impl Property { constant: None, offset: None, encoding: None, + xor_mask: None, } } @@ -309,6 +314,7 @@ impl Property { constant: Some(constant.into()), offset: None, encoding: None, + xor_mask: None, } } @@ -877,6 +883,9 @@ pub enum YamlProperty { /// Raw-layout only: how this field's bytes are produced #[serde(default)] encoding: Option, + /// Raw-layout only: XOR key applied to 8-byte encodings before writing + #[serde(default)] + xor_mask: Option, }, } @@ -892,6 +901,7 @@ impl From for Property { constant, offset, encoding, + xor_mask, } => { let kind = match kind.as_deref() { Some("constant_ref") => PropertyKind::ConstantRef, @@ -905,6 +915,7 @@ impl From for Property { constant, offset, encoding, + xor_mask, } } } @@ -1016,24 +1027,40 @@ pub enum RawEncoding { count: usize, stride: usize, }, + /// An unsigned byte written to `count` slots, `stride` bytes apart. Same reasoning as + /// [`RawEncoding::I32Strided`], for per-record flags. + U8Strided { + count: usize, + stride: usize, + }, /// A base58 pubkey, written as 32 bytes. Bytes32, /// The slot the override materializes at, plus the supplied signed offset. `lead` is used only /// when the caller explicitly supplies JSON `null`; omitting the property performs no write. /// A negative offset before slot zero saturates at zero, while positive overflow is rejected. + /// + /// `width` is the byte width of the on-chain field: 8 by default, or 4 for programs that store + /// the slot as a u32. A 4-byte slot past u32::MAX is refused instead of truncated. Slot { lead: i64, + #[serde(default = "default_slot_width")] + width: usize, }, } +fn default_slot_width() -> usize { + 8 +} + impl RawEncoding { /// Byte width of this encoding. pub fn width(&self) -> usize { match self { - RawEncoding::U8 => 1, + RawEncoding::U8 | RawEncoding::U8Strided { .. } => 1, RawEncoding::U16 => 2, RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, - RawEncoding::U64 | RawEncoding::I64 | RawEncoding::Slot { .. } => 8, + RawEncoding::U64 | RawEncoding::I64 => 8, + RawEncoding::Slot { width, .. } => *width, RawEncoding::U128 | RawEncoding::I128 => 16, RawEncoding::Bytes32 => 32, } @@ -1045,7 +1072,8 @@ impl RawEncoding { /// encodings with the same loop instead of special-casing one of them. pub fn placements(&self) -> (usize, usize) { match self { - RawEncoding::I32Strided { count, stride } => (*count, *stride), + RawEncoding::I32Strided { count, stride } + | RawEncoding::U8Strided { count, stride } => (*count, *stride), other => (1, other.width()), } } @@ -1079,7 +1107,7 @@ impl RawEncoding { }}; } Ok(match self { - RawEncoding::U8 => int!(u8, "u8"), + RawEncoding::U8 | RawEncoding::U8Strided { .. } => int!(u8, "u8"), RawEncoding::U16 => int!(u16, "u16"), RawEncoding::U32 => int!(u32, "u32"), RawEncoding::U64 => int!(u64, "u64"), @@ -1096,7 +1124,7 @@ impl RawEncoding { .to_bytes() .to_vec() } - RawEncoding::Slot { lead } => { + RawEncoding::Slot { lead, width } => { let lead = match value { serde_json::Value::Null => *lead, _ => { @@ -1112,7 +1140,14 @@ impl RawEncoding { } else { target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0) }; - slot.to_le_bytes().to_vec() + match width { + 8 => slot.to_le_bytes().to_vec(), + 4 => u32::try_from(slot) + .map_err(|_| format!("slot {slot} does not fit a 4-byte slot field"))? + .to_le_bytes() + .to_vec(), + other => return Err(format!("slot width must be 4 or 8, not {other}")), + } } }) } @@ -1143,6 +1178,23 @@ impl OverrideTemplate { property.path ) })?; + if let RawEncoding::Slot { width, .. } = encoding + && *width != 4 + && *width != 8 + { + return Err(format!( + "writable raw-layout property '{}': slot width must be 4 or 8, not {width}", + property.path + )); + } + if property.xor_mask.is_some() && encoding.width() != 8 { + return Err(format!( + "property '{}' declares an xor_mask but its encoding is {} bytes; only \ + 8-byte encodings can be masked", + property.path, + encoding.width() + )); + } let (count, stride) = encoding.placements(); if count == 0 { return Err(format!( @@ -1196,7 +1248,19 @@ impl OverrideTemplate { else { return Err(format!("property '{name}' has no offset or encoding")); }; - let bytes = encoding.encode(value, target_slot)?; + let mut bytes = encoding.encode(value, target_slot)?; + if let Some(mask) = property.xor_mask { + if bytes.len() != 8 { + return Err(format!( + "property '{name}' declares an xor_mask but its encoding is {} bytes; only \ + 8-byte encodings can be masked", + bytes.len() + )); + } + for (byte, key) in bytes.iter_mut().zip(mask.to_le_bytes()) { + *byte ^= key; + } + } let (count, stride) = encoding.placements(); for i in 0..count { let at = offset @@ -1573,46 +1637,46 @@ mod tests { assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); // The supplied value is the lead, so one property covers live and stale. - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-5), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 495); // The manifest lead is the default, used when no value is given. - let bytes = RawEncoding::Slot { lead: -1 } + let bytes = RawEncoding::Slot { lead: -1, width: 8 } .encode(&json!(null), 500) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); // A lead that would go below zero clamps rather than wrapping. - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-10), 3) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); // Slot is a u64. Values above i64::MAX must not wrap through a signed cast and become zero. let large_slot = i64::MAX as u64 + 1; - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), large_slot) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(-1), u64::MAX) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX - 1); - let bytes = RawEncoding::Slot { lead: 0 } + let bytes = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(0), u64::MAX) .unwrap(); assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); - let err = RawEncoding::Slot { lead: 0 } + let err = RawEncoding::Slot { lead: 0, width: 8 } .encode(&json!(1), u64::MAX) .expect_err("a positive lead must not wrap past u64::MAX"); assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); @@ -1720,6 +1784,221 @@ mod tests { assert!(err.contains("smaller than its 4 byte width"), "{err}"); } + #[test] + fn u8_strided_round_trips_and_rejects_out_of_range_values() { + use super::RawEncoding; + + let encoding = RawEncoding::U8Strided { + count: 3, + stride: 8, + }; + let serialized = json!({"u8_strided": {"count": 3, "stride": 8}}); + assert_eq!(serde_json::to_value(&encoding).unwrap(), serialized); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + encoding + ); + for value in [json!(-1), json!(256), json!("256")] { + let err = encoding.encode(&value, 0).expect_err("outside u8 range"); + assert!(err.contains("invalid u8"), "unexpected error: {err}"); + } + } + + #[test] + fn u8_strided_writes_single_bytes_and_preserves_padding() { + use super::RawEncoding; + + let mut property = Property::field("flags".to_string()); + property.offset = Some(2); + property.encoding = Some(RawEncoding::U8Strided { + count: 3, + stride: 8, + }); + let template = raw_template(vec![property]); + template.validate_raw_layout().unwrap(); + for value in [0, 255] { + let out = template + .materialize_raw_layout( + &[0xa5; 19], + &HashMap::from([("flags".to_string(), json!(value))]), + 0, + ) + .unwrap(); + let mut expected = [0xa5; 19]; + for offset in [2, 10, 18] { + expected[offset] = value; + } + assert_eq!(out, expected); + } + } + + #[test] + fn u8_strided_rejects_invalid_run_bounds_count_and_stride() { + use super::RawEncoding; + + for (offset, count, stride, message) in [ + (2, 0, 8, "zero placements"), + (2, 3, usize::MAX, "stride overflow"), + (usize::MAX, 1, 1, "offset overflow"), + ] { + let mut property = Property::field("flags".to_string()); + property.offset = Some(offset); + property.encoding = Some(RawEncoding::U8Strided { count, stride }); + let err = raw_template(vec![property]) + .validate_raw_layout() + .expect_err("invalid strided run"); + assert!(err.contains(message), "unexpected error: {err}"); + } + + // The account length is only known at write time, so a run that ends one byte past the + // account is refused by the bounds check. + let mut property = Property::field("flags".to_string()); + property.offset = Some(3); + property.encoding = Some(RawEncoding::U8Strided { + count: 3, + stride: 8, + }); + let err = raw_template(vec![property]) + .materialize_raw_layout( + &[0u8; 19], + &HashMap::from([("flags".to_string(), json!(1))]), + 0, + ) + .expect_err("a run crossing the end must be refused"); + assert!(err.contains("exceeds"), "unexpected error: {err}"); + } + + #[test] + fn slot_width_defaults_to_eight_and_narrows_to_four() { + use super::RawEncoding; + + // Manifests that spell no width keep the historical 8-byte slot bytes exactly. + let parsed: RawEncoding = serde_json::from_value(json!({"slot": {"lead": -20}})).unwrap(); + assert_eq!( + parsed, + RawEncoding::Slot { + lead: -20, + width: 8 + } + ); + + let narrow: RawEncoding = + serde_json::from_value(json!({"slot": {"lead": 0, "width": 4}})).unwrap(); + assert_eq!(narrow.width(), 4); + assert_eq!( + narrow.encode(&json!(null), 500).unwrap(), + 500u32.to_le_bytes().to_vec() + ); + + let err = narrow + .encode(&json!(null), u64::from(u32::MAX) + 1) + .expect_err("a slot past u32::MAX must not be truncated"); + assert!(err.contains("4-byte"), "unexpected error: {err}"); + + let err = RawEncoding::Slot { lead: 0, width: 2 } + .encode(&json!(null), 500) + .expect_err("only widths 4 and 8 exist"); + assert!(err.contains("must be 4 or 8"), "unexpected error: {err}"); + + let mut property = Property::field("updated_at".to_string()); + property.offset = Some(0); + property.encoding = Some(RawEncoding::Slot { lead: 0, width: 2 }); + let err = raw_template(vec![property]) + .validate_raw_layout() + .expect_err("an unsupported slot width must fail at load"); + assert!(err.contains("must be 4 or 8"), "unexpected error: {err}"); + } + + #[test] + fn xor_mask_stores_the_masked_word_and_reads_back_plaintext() { + use super::RawEncoding; + + // A program that keeps every word as `plaintext XOR key`. The template value stays + // plaintext; the engine masks it on write, so the account holds value ^ key. + fn masked_property(encoding: RawEncoding, key: u64) -> Property { + let mut property = Property::field("word".to_string()); + property.offset = Some(0); + property.encoding = Some(encoding); + property.xor_mask = Some(key); + property + } + + let fair_value_key: u64 = 0xb957_ed15_dc87_7426; + let state_key: u64 = 0x6e9d_e2b3_0b19_f1ea; + for (encoding, key, value, slot, plaintext) in [ + ( + RawEncoding::U64, + fair_value_key, + json!("29278243997902"), + 0, + 29_278_243_997_902u64, + ), + ( + RawEncoding::Slot { lead: 0, width: 8 }, + state_key, + serde_json::Value::Null, + 444_223_940, + 444_223_940, + ), + // A negative lead ages the quote, still masked. + ( + RawEncoding::Slot { lead: -3, width: 8 }, + state_key, + serde_json::Value::Null, + 444_223_940, + 444_223_937, + ), + ] { + let template = raw_template(vec![masked_property(encoding, key)]); + template.validate_raw_layout().expect("masked 8-byte field"); + let out = template + .materialize_raw_layout( + &[0u8; 8], + &HashMap::from([("word".to_string(), value)]), + slot, + ) + .expect("masked write"); + assert_eq!( + u64::from_le_bytes(out[0..8].try_into().unwrap()), + plaintext ^ key, + "the account must hold the masked word for {plaintext}" + ); + } + } + + #[test] + fn xor_mask_rejects_narrow_encodings() { + use super::RawEncoding; + + // A mask on a non-8-byte encoding is a template error, not a silent half-write. + let mut narrow = Property::field("small".to_string()); + narrow.offset = Some(0); + narrow.encoding = Some(RawEncoding::U32); + narrow.xor_mask = Some(0xdead_beef); + let err = raw_template(vec![narrow]) + .materialize_raw_layout( + &[0u8; 16], + &HashMap::from([("small".to_string(), json!(1))]), + 0, + ) + .expect_err("a mask on a 4-byte encoding must be refused"); + assert!(err.contains("8-byte"), "unexpected error: {err}"); + } + + #[test] + fn xor_mask_on_a_narrow_encoding_fails_at_load() { + use super::RawEncoding; + + let mut narrow = Property::field("updated_at".to_string()); + narrow.offset = Some(0); + narrow.encoding = Some(RawEncoding::Slot { lead: 0, width: 4 }); + narrow.xor_mask = Some(0xdead_beef); + let err = raw_template(vec![narrow]) + .validate_raw_layout() + .expect_err("a mask on a 4-byte slot must be refused at load"); + assert!(err.contains("8-byte"), "unexpected error: {err}"); + } + #[test] fn u16_be_ref_rejects_out_of_range_values() { let seed = PdaSeed::U16BeRef("index".to_string()); From 0004e589046c3798ccfc00c694cf2ab6cbe730e2 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 24 Sep 2026 09:33:34 +0300 Subject: [PATCH 18/20] refactor(goonfi): adopt boolean raw-layout templates --- .../src/scenarios/protocols/goonfi/README.md | 9 +- .../protocols/goonfi/v1/liquidity.rs | 50 +++++-- .../protocols/goonfi/v1/market_overrides.yaml | 8 +- .../scenarios/protocols/goonfi/v1/markets.rs | 26 ++-- .../src/scenarios/protocols/goonfi/v1/mod.rs | 14 +- .../protocols/goonfi/v1/oracle_overrides.yaml | 19 ++- .../scenarios/protocols/goonfi/v1/price.rs | 138 ++++++++++-------- crates/core/src/tests/goonfi/mod.rs | 31 ++-- 8 files changed, 175 insertions(+), 120 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md index 4fa0a236a..b985ce1c6 100644 --- a/crates/core/src/scenarios/protocols/goonfi/README.md +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -18,9 +18,12 @@ an upgrade-monitoring service or a claim that every future deployment has the sa offset and the usage notes (`llm_context`) for the four templates: `goonfi-price` and `goonfi-reference-band` move the oracle bid/ask and the market's reference band as one invariant; `goonfi-stale-quote` and `goonfi-freshness` write the oracle's 4-byte slot with -a default lead of -2000 or 0. Both layouts declare their program owner; the shared -materializer and builders use the same owner predicate. The materializer then validates size, -optional magic bytes and write bounds. A failed owner check skips the override with a warning. +a default lead of -2000 or 0. Both collections set `raw_layout: true`. The builders validate +owner, size and tag before composing any override; the raw template path is bounds-checked +only, so picking the right account is the caller's job, and Studio offers discovered markets. +Known limitation: generic paths (the `create_scenario` MCP tool, the HTTP scenario API, +Studio's generic editor and later edits of a built scenario) reach the raw write without +those checks, so an oracle template aimed at the market address is not rejected. ## Catalog and price scenario diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs index 8dcce45de..f914eefc9 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -334,6 +334,8 @@ mod tests { owner: Pubkey::new_unique(), ..market_account() }; + let mut oversized_market = market_account(); + oversized_market.data.push(0); let base = vault(&WSOL, 1); let foreign_vault = Account { owner: Pubkey::new_unique(), @@ -360,6 +362,7 @@ mod tests { &oracle, 10_001, 0, + "0..=10000 basis points", ), ( "both sides left unchanged", @@ -369,8 +372,9 @@ mod tests { &oracle, FULL_BPS, FULL_BPS, + "both vaults left unchanged", ), - // A foreign account of the same size passes the raw guard, so the owner check must reject. + // A foreign account with the right size and tag must still fail the owner check. ( "market owned by another program", MARKET, @@ -379,6 +383,17 @@ mod tests { &oracle, 0, 0, + "market is not owned by GoonFi", + ), + ( + "market one byte too long", + MARKET, + &oversized_market, + &base, + &oracle, + 0, + 0, + "market is 2049 bytes, expected 2048", ), // A vault not owned by a token program is not a real vault. ( @@ -389,8 +404,9 @@ mod tests { &oracle, 0, 0, + "base vault is not owned by a supported token program", ), - // A foreign oracle carries no magic, so its owner is the only discriminator. + // A foreign oracle has no tag, so its owner is the only discriminator. ( "oracle owned by another program", MARKET, @@ -399,6 +415,7 @@ mod tests { &foreign_oracle, 0, 0, + "oracle is not owned by the GoonFi publisher", ), // An owner-and-length check would pass a mint: it is token-program-owned and long // enough to misread an amount out of. Unpacking plus the mint comparison is what @@ -411,6 +428,7 @@ mod tests { &oracle, 0, 0, + "base vault is not a token account", ), // A real token account holding the other side's mint is refused as well. ( @@ -421,6 +439,7 @@ mod tests { &oracle, 0, 0, + "but the market's base mint is", ), // The market account carries no self-address, so the vault's authority is what ties // the requested market to these bytes. @@ -432,22 +451,23 @@ mod tests { &oracle, 0, 0, + "not by the market", ), ]; - for (label, market, market_data, base, oracle, base_bps, quote_bps) in rows { - assert!( - build_goonfi_liquidity_scenario( - market, - market_data, - (FIXTURE_BASE_VAULT, base), - (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), - (FIXTURE_ORACLE, oracle), - base_bps, - quote_bps, - ) - .is_err(), - "{label}" + for (label, market, market_data, base, oracle, base_bps, quote_bps, expected) in rows { + let result = build_goonfi_liquidity_scenario( + market, + market_data, + (FIXTURE_BASE_VAULT, base), + (FIXTURE_QUOTE_VAULT, &vault(&USDC, 1)), + (FIXTURE_ORACLE, oracle), + base_bps, + quote_bps, ); + let Err(error) = result else { + panic!("{label}: expected an error"); + }; + assert!(error.to_string().contains(expected), "{label}: {error}"); } } diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml index 6a218ccfa..a6046c352 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml +++ b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml @@ -6,12 +6,8 @@ account_type: MarketState # tagged by the 8 magic bytes every live market shares. Mints, vaults and the oracle pointer live # in cleartext at fixed offsets; the only fields a product flow writes are the two reference # prices the deployed program uses as an anti-manipulation band around the oracle. -raw_layout: - account_size: 2048 - owner: goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE - magic: - offset: 0 - bytes: [48, 188, 47, 53, 52, 88, 50, 154] +# Observed: 2048 bytes, tag [48, 188, 47, 53, 52, 88, 50, 154] at 0, owner goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE. +raw_layout: true tags: - pmm diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs index ec98015aa..984271097 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -10,10 +10,13 @@ use solana_commitment_config::CommitmentConfig; use solana_pubkey::Pubkey; use surfpool_types::VERIFIED_TOKENS_BY_SYMBOL; -use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket, vault_addresses}; +use super::{ + GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket, + price::{MARKET_ACCOUNT_SIZE, MARKET_TAG, MARKET_TAG_OFFSET}, + vault_addresses, +}; use crate::{ error::{SurfpoolError, SurfpoolResult}, - scenarios::TemplateRegistry, surfnet::remote::SurfnetRemoteClient, types::MintAccount, }; @@ -104,18 +107,13 @@ fn resolve_market( pub async fn discover_goonfi_markets( client: &SurfnetRemoteClient, ) -> SurfpoolResult> { - let registry = TemplateRegistry::new(); - let layout = registry - .get("goonfi-reference-band") - .and_then(|template| template.raw_layout.as_ref()) - .ok_or_else(|| SurfpoolError::internal("GoonFi market layout is unavailable"))?; - let mut filters = vec![RpcFilterType::DataSize(layout.account_size as u64)]; - if let Some(magic) = &layout.magic { - filters.push(RpcFilterType::Memcmp(Memcmp::new_raw_bytes( - magic.offset, - magic.bytes.clone(), - ))); - } + let filters = vec![ + RpcFilterType::DataSize(MARKET_ACCOUNT_SIZE as u64), + RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + MARKET_TAG_OFFSET, + MARKET_TAG.to_vec(), + )), + ]; let accounts = client .get_program_accounts( &GOONFI_PROGRAM_ID, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs index dafaeef34..9431900e8 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -64,7 +64,10 @@ mod fixtures { use super::{ GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, liquidity::{BASE_MINT_OFFSET, BASE_VAULT_OFFSET, QUOTE_MINT_OFFSET, QUOTE_VAULT_OFFSET}, - price::{MARKET_LAYOUT, ORACLE_LAYOUT, ORACLE_POINTER_OFFSET}, + price::{ + MARKET_ACCOUNT_SIZE, MARKET_TAG, MARKET_TAG_OFFSET, ORACLE_ACCOUNT_SIZE, + ORACLE_POINTER_OFFSET, + }, }; pub(super) const FIXTURE_BASE_VAULT: Pubkey = Pubkey::new_from_array([2; 32]); @@ -72,15 +75,14 @@ mod fixtures { pub(super) const FIXTURE_ORACLE: Pubkey = Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); - /// A market of the manifest's size and magic, pointing at the given mints, vaults and oracle. + /// A market of the observed size and tag, pointing at the given mints, vaults and oracle. pub(super) fn market_account( mints: [&Pubkey; 2], vaults: [&Pubkey; 2], oracle: &Pubkey, ) -> Account { - let mut data = vec![0; MARKET_LAYOUT.account_size]; - let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); - data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + let mut data = vec![0; MARKET_ACCOUNT_SIZE]; + data[MARKET_TAG_OFFSET..MARKET_TAG_OFFSET + MARKET_TAG.len()].copy_from_slice(&MARKET_TAG); for (offset, pointer) in [ (BASE_MINT_OFFSET, mints[0]), (QUOTE_MINT_OFFSET, mints[1]), @@ -99,7 +101,7 @@ mod fixtures { pub(super) fn oracle_account() -> Account { Account { - data: vec![0; ORACLE_LAYOUT.account_size], + data: vec![0; ORACLE_ACCOUNT_SIZE], owner: GOONFI_ORACLE_PROGRAM_ID, ..Account::default() } diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml index 2545fd448..9372dddef 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml +++ b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml @@ -3,12 +3,10 @@ version: deployed-438563879 account_type: PriceOracle # The write target of every template here is the market's price-oracle account: 32 bytes owned by -# the companion publisher program. It carries no discriminator, so the byte guard can only pin -# the size; the owner predicate below is what keeps a raw write out of a foreign 32-byte account, -# and the builder additionally resolves the oracle through the market account's own pointer. -raw_layout: - account_size: 32 - owner: dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu +# the companion publisher program. It carries no discriminator, so the builders check its owner and +# resolve it through the market account's own pointer before composing a write. +# Observed: 32 bytes, no tag, owner dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu. +raw_layout: true tags: - pmm @@ -35,6 +33,9 @@ templates: label: Ask price description: "The human pair price the venue sells base at, multiplied by 10^6. Live oracles publish it at or above the bid; keep that shape." llm_context: | + Target the market's oracle: the "oracle" address from list_goonfi_markets, never the + market "address". A raw write is bounds-checked only, so the wrong account is not rejected. + SET BOTH FIELDS AS ONE INVARIANT, with ask >= bid. Values are the human pair price times 10^6 regardless of mint decimals: SOL at 99.74 USDC is bid_price_x1e6 "99740000". Use decimal integer strings, not JSON numbers. @@ -75,6 +76,9 @@ templates: negative integer. Pass null to use -2000, past every observed window including the stablecoin tier's. llm_context: | + Target the market's oracle: the "oracle" address from list_goonfi_markets, never the + market "address". A raw write is bounds-checked only, so the wrong account is not rejected. + The value you pass IS the lead: Surfpool writes the materialization slot plus it, clamped at zero. Pass null to take the -2000 default. The slot field is 4 bytes; the dynamic multiplier stored beside it stays untouched. @@ -114,6 +118,9 @@ templates: label: Current materialization slot description: Slot lead, as an integer. Pass null to take the lead of zero and write the materialization slot itself. llm_context: | + Target the market's oracle: the "oracle" address from list_goonfi_markets, never the + market "address". A raw write is bounds-checked only, so the wrong account is not rejected. + GoonFi rejects a swap whose oracle has aged past its per-market window with custom error 0x15 - a loud failure, unlike BisonFi's silent zero - and decays the quote with age before that, at a rate proportional to the oracle's multiplier at offset 20. Re-stamping this diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs index 22f0f8fb2..0c53fd1b2 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -6,21 +6,17 @@ //! and validated by owner, and a price move is one invariant across two accounts - oracle bid and //! ask, the market's reference band, and a freshness re-stamp. -use std::{collections::HashMap, sync::LazyLock}; +use std::collections::HashMap; use solana_account::Account; use solana_pubkey::Pubkey; -use surfpool_types::{AccountAddress, OverrideInstance, RawLayout, Scenario}; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; use super::{ FRESHNESS_TEMPLATE, PREPARATION_SLOT, freshness_override, invalid, market_label, markets::market_references, read_pubkey, template, vault_addresses, }; -use crate::{ - error::{SurfpoolError, SurfpoolResult}, - scenarios::TemplateRegistry, - types::TokenAccount, -}; +use crate::{error::SurfpoolResult, scenarios::TemplateRegistry, types::TokenAccount}; pub const GOONFI_PROGRAM_ID: Pubkey = Pubkey::from_str_const("goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"); @@ -33,23 +29,10 @@ pub const GOONFI_DEFAULT_MARKET: Pubkey = /// Read, never written, so no template declares it. pub(super) const ORACLE_POINTER_OFFSET: usize = 208; -/// The layouts a GoonFi market and its oracle must have, taken from the manifests the raw -/// templates are written against so there is one definition of them. Built once; both manifests -/// are compiled in. -pub(super) static ORACLE_LAYOUT: LazyLock = LazyLock::new(|| layout_of(PRICE_TEMPLATE)); -pub(super) static MARKET_LAYOUT: LazyLock = - LazyLock::new(|| layout_of(REFERENCE_TEMPLATE)); - -fn layout_of(template_id: &str) -> RawLayout { - template(&TemplateRegistry::new(), template_id) - .and_then(|template| { - template - .raw_layout - .clone() - .ok_or_else(|| SurfpoolError::internal("the GoonFi manifests carry no raw layout")) - }) - .expect("the GoonFi manifests are compiled in and always parse") -} +pub(super) const MARKET_ACCOUNT_SIZE: usize = 2048; +pub(super) const MARKET_TAG_OFFSET: usize = 0; +pub(super) const MARKET_TAG: [u8; 8] = [48, 188, 47, 53, 52, 88, 50, 154]; +pub(super) const ORACLE_ACCOUNT_SIZE: usize = 32; const PRICE_TEMPLATE: &str = "goonfi-price"; const REFERENCE_TEMPLATE: &str = "goonfi-reference-band"; @@ -154,25 +137,36 @@ fn validate_market_authority(address: Pubkey, base_vault_account: &Account) -> S } /// Rejects an account that is not a GoonFi market. -/// -/// The owner and byte guards use the same manifest as the materializer, so a builder cannot -/// accept an account that the template's owner predicate would later reject. pub fn validate_goonfi_market_layout(account: &Account) -> SurfpoolResult<()> { - MARKET_LAYOUT - .guard_owner(&account.owner) - .map_err(|_| invalid("market is not owned by GoonFi"))?; - MARKET_LAYOUT.guard(&account.data).map_err(invalid) + if account.owner != GOONFI_PROGRAM_ID { + return Err(invalid("market is not owned by GoonFi")); + } + if account.data.len() != MARKET_ACCOUNT_SIZE { + return Err(invalid(format!( + "market is {} bytes, expected {MARKET_ACCOUNT_SIZE}", + account.data.len() + ))); + } + if account.data[MARKET_TAG_OFFSET..MARKET_TAG_OFFSET + MARKET_TAG.len()] != MARKET_TAG { + return Err(invalid("market does not carry the GoonFi market tag")); + } + Ok(()) } /// Rejects an account that is not a GoonFi price oracle. /// -/// The oracle is 32 bytes with no magic at all, so its guard pins only the size; the owner check -/// here is the real discriminator. +/// The oracle is 32 bytes with no tag at all, so the owner check is its real discriminator. pub fn validate_goonfi_oracle_layout(account: &Account) -> SurfpoolResult<()> { - ORACLE_LAYOUT - .guard_owner(&account.owner) - .map_err(|_| invalid("oracle is not owned by the GoonFi publisher"))?; - ORACLE_LAYOUT.guard(&account.data).map_err(invalid) + if account.owner != GOONFI_ORACLE_PROGRAM_ID { + return Err(invalid("oracle is not owned by the GoonFi publisher")); + } + if account.data.len() != ORACLE_ACCOUNT_SIZE { + return Err(invalid(format!( + "oracle is {} bytes, expected {ORACLE_ACCOUNT_SIZE}", + account.data.len() + ))); + } + Ok(()) } #[derive(Clone, Debug, PartialEq)] @@ -305,16 +299,25 @@ mod tests { }; #[test] - fn template_owners_match_the_discovered_programs() { + fn oracle_slot_templates_write_four_bytes_and_spare_the_multiplier() { let registry = TemplateRegistry::new(); - for (id, owner) in [ - (REFERENCE_TEMPLATE, GOONFI_PROGRAM_ID), - (PRICE_TEMPLATE, GOONFI_ORACLE_PROGRAM_ID), - (FRESHNESS_TEMPLATE, GOONFI_ORACLE_PROGRAM_ID), - ("goonfi-stale-quote", GOONFI_ORACLE_PROGRAM_ID), + let oracle = [0xaa; ORACLE_ACCOUNT_SIZE]; + for (id, expected_slot) in [ + ("goonfi-stale-quote", 8_000u32), + (FRESHNESS_TEMPLATE, 10_000), ] { - let layout = registry.get(id).unwrap().raw_layout.as_ref().unwrap(); - assert_eq!(layout.owner, Some(owner.to_string()), "{id}"); + let written = registry + .get(id) + .unwrap() + .materialize_raw_layout( + &oracle, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + 10_000, + ) + .unwrap(); + assert_eq!(written[16..20], expected_slot.to_le_bytes(), "{id}"); + assert_eq!(written[..16], oracle[..16], "{id}"); + assert_eq!(written[20..], oracle[20..], "{id}"); } } @@ -547,54 +550,69 @@ mod tests { owner: Pubkey::new_unique(), ..market_account(&oracle) }; - // The raw guard cannot see the owner, which is the whole reason this check sits on top. - assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); let mut bad_magic = market_account(&oracle); bad_magic.data[0] ^= 0xff; - // The oracle carries no magic at all, so the owner check is its only discriminator. + let mut oversized = market_account(&oracle); + oversized.data.push(0); + // The oracle carries no tag at all, so the owner check is its only discriminator. let foreign_oracle = Account { owner: Pubkey::new_unique(), ..oracle_account() }; - assert!(ORACLE_LAYOUT.guard(&foreign_oracle.data).is_ok()); - for (label, market, oracle_fixture) in [ + for (label, market, oracle_fixture, expected) in [ ( "market owned by another program", wrong_owner, oracle_account(), + "market is not owned by GoonFi", ), ( "market with a flipped magic byte", bad_magic, oracle_account(), + "market does not carry the GoonFi market tag", + ), + ( + "market one byte too long", + oversized, + oracle_account(), + "market is 2049 bytes, expected 2048", ), ( "market without an oracle pointer", market_account(&Pubkey::default()), oracle_account(), + "market carries no oracle pointer", ), ( "oracle owned by another program", market_account(&oracle), foreign_oracle, + "oracle is not owned by the GoonFi publisher", ), ] { - assert!( - GoonfiMarket::validate( - address, - &market, - (FIXTURE_BASE_VAULT, &vault_account(&address)), - (oracle, &oracle_fixture), - ) - .is_err(), - "{label}" + let result = GoonfiMarket::validate( + address, + &market, + (FIXTURE_BASE_VAULT, &vault_account(&address)), + (oracle, &oracle_fixture), ); + let Err(error) = result else { + panic!("{label}: expected an error"); + }; + assert!(error.to_string().contains(expected), "{label}: {error}"); } let truncated_oracle = Account { data: vec![0; 16], ..oracle_account() }; - assert!(validate_goonfi_oracle_layout(&truncated_oracle).is_err()); + let error = validate_goonfi_oracle_layout(&truncated_oracle).unwrap_err(); + assert!( + error + .to_string() + .contains("oracle is 16 bytes, expected 32"), + "{error}" + ); } } diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs index 10e735d73..059a2dbb7 100644 --- a/crates/core/src/tests/goonfi/mod.rs +++ b/crates/core/src/tests/goonfi/mod.rs @@ -547,6 +547,25 @@ async fn fork_from_market(market: &'static str, amount_in: u64) -> GoonfiFork { goonfi_fork(spec).await } +/// Every shipped GoonFi template must explicitly select the raw byte-write path. +#[test] +fn goonfi_templates_select_raw_layout_writes() { + let registry = TemplateRegistry::new(); + let templates: Vec<_> = registry + .all() + .into_iter() + .filter(|template| template.id.starts_with("goonfi-")) + .collect(); + assert!(!templates.is_empty(), "GoonFi templates must be registered"); + for template in templates { + assert!( + template.raw_layout, + "{} must select raw-layout writes", + template.id + ); + } +} + /// Materializes the goonfi-stale-quote template with its default lead onto the fork's live /// oracle bytes, asserts the exact 4-byte slot it wrote, and proves the deployed program then /// rejects the swap. This is the template's own default doing the aging, not a hand-picked age. @@ -555,12 +574,8 @@ fn stale_template_default_rejects(fork: &GoonfiFork, amount: u64) { let stale = registry.get("goonfi-stale-quote").expect("stale template"); let snapshot_slot = oracle_slot(&fork.oracle.data); let aged = stale - .raw_layout - .as_ref() - .expect("oracle raw layout") - .materialize( + .materialize_raw_layout( &fork.oracle.data, - &stale.properties, &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), snapshot_slot, ) @@ -610,12 +625,8 @@ async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { let registry = TemplateRegistry::new(); let price_template = registry.get("goonfi-price").expect("price template"); let priced = price_template - .raw_layout - .as_ref() - .expect("oracle raw layout") - .materialize( + .materialize_raw_layout( &fork.oracle.data, - &price_template.properties, &HashMap::from([ ("bid_price_x1e6".to_string(), serde_json::json!("123456789")), ("ask_price_x1e6".to_string(), serde_json::json!("123456790")), From 6898e47c1138f642fffd017245dd4ec6e20fee2e Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 24 Sep 2026 09:54:38 +0300 Subject: [PATCH 19/20] test(scenarios): prove 4-byte slots and keep slot JSON stable --- crates/core/src/scenarios/README.md | 3 +- crates/mcp/src/surfpool/mod.rs | 2 +- crates/types/src/scenarios.rs | 66 ++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 212189039..cc756ea40 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -79,7 +79,8 @@ A few encodings cover layouts that plain integers can't: repeated across fixed-size records. - `slot: { lead, width }` writes the target slot plus `lead`. `width` is 4 or 8 and defaults to 8. - `xor_mask: ` on a property XORs its 8-byte encoded word with the key before writing, for - programs that store masked values. Narrower encodings are rejected at load. + programs that store masked values. Encodings that are not 8 bytes wide, narrower or wider, are + rejected at load. Each collection uses exactly one write model: an IDL-backed collection cannot set `raw_layout: true`, and an IDL-less collection must set it. Raw-layout collections are currently diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index e79af19fc..d329e756f 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -1535,7 +1535,7 @@ mod tests { } #[test] - fn compact_template_json_exports_the_raw_layout_flag() { + fn compact_template_json_exports_the_raw_layout_flag_and_xor_masks() { let registry = TemplateRegistry::new(); let mut template = registry .get("pyth-price-feed-v2") diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index afe2dc136..8076942b6 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1043,7 +1043,10 @@ pub enum RawEncoding { /// the slot as a u32. A 4-byte slot past u32::MAX is refused instead of truncated. Slot { lead: i64, - #[serde(default = "default_slot_width")] + #[serde( + default = "default_slot_width", + skip_serializing_if = "is_default_slot_width" + )] width: usize, }, } @@ -1052,6 +1055,10 @@ fn default_slot_width() -> usize { 8 } +fn is_default_slot_width(width: &usize) -> bool { + *width == default_slot_width() +} + impl RawEncoding { /// Byte width of this encoding. pub fn width(&self) -> usize { @@ -1909,6 +1916,55 @@ mod tests { assert!(err.contains("must be 4 or 8"), "unexpected error: {err}"); } + #[test] + fn slot_width_is_only_serialized_when_it_is_not_the_default() { + use super::RawEncoding; + + assert_eq!( + serde_json::to_value(RawEncoding::Slot { + lead: -20, + width: 8 + }) + .unwrap(), + json!({"slot": {"lead": -20}}) + ); + assert_eq!( + serde_json::to_value(RawEncoding::Slot { + lead: -20, + width: 4 + }) + .unwrap(), + json!({"slot": {"lead": -20, "width": 4}}) + ); + } + + #[test] + fn four_byte_slot_writes_only_its_four_bytes() { + use super::RawEncoding; + + let mut property = Property::field("updated_at".to_string()); + property.offset = Some(16); + property.encoding = Some(RawEncoding::Slot { + lead: -2000, + width: 4, + }); + let template = raw_template(vec![property]); + template.validate_raw_layout().unwrap(); + + for (target_slot, expected) in [(10_000, 8_000u32), (1_500, 0u32)] { + let out = template + .materialize_raw_layout( + &[0xa5; 24], + &HashMap::from([("updated_at".to_string(), json!(null))]), + target_slot, + ) + .unwrap(); + assert_eq!(out[16..20], expected.to_le_bytes()); + assert!(out[..16].iter().all(|b| *b == 0xa5)); + assert!(out[20..].iter().all(|b| *b == 0xa5)); + } + } + #[test] fn xor_mask_stores_the_masked_word_and_reads_back_plaintext() { use super::RawEncoding; @@ -1948,6 +2004,14 @@ mod tests { 444_223_940, 444_223_937, ), + // Any 8-byte encoding can be masked, signed included. + ( + RawEncoding::I64, + fair_value_key, + json!(-5), + 0, + (-5i64) as u64, + ), ] { let template = raw_template(vec![masked_property(encoding, key)]); template.validate_raw_layout().expect("masked 8-byte field"); From 9be1c60c9f3da8ee8bf4ecc6010a58dcc3ec8708 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Thu, 24 Sep 2026 09:56:46 +0300 Subject: [PATCH 20/20] test(goonfi): pin the current GoonFi deployment --- .../src/scenarios/protocols/goonfi/README.md | 15 ++++++++-- .../protocols/goonfi/v1/market_overrides.yaml | 2 +- .../protocols/goonfi/v1/oracle_overrides.yaml | 2 +- crates/core/src/tests/goonfi/mod.rs | 28 +++++++++++++------ 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/crates/core/src/scenarios/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md index b985ce1c6..e2b2bccd0 100644 --- a/crates/core/src/scenarios/protocols/goonfi/README.md +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -12,6 +12,9 @@ size, deployment slot and ELF SHA-256 (the constants at the top of that file) be replaying the program. A deployment change requires revalidation. These are test pins, not an upgrade-monitoring service or a claim that every future deployment has the same layout. +Current trading program: slot 448984043, ELF SHA-256 `77241d6c…4a60`, revalidated 2026-09-24. +Previous: slot 438563879, `73e58083…6c2e`. The oracle publisher is unchanged since slot 404369628. + ## Layouts and templates `market_overrides.yaml` and `oracle_overrides.yaml` carry the two layouts, every field @@ -85,9 +88,15 @@ using a builtin wrapper for the Jupiter-shaped CPI. It checks: path on two markets, preserving unrelated bytes and refreshing the u32 slot afterwards. - Live discovery returns valid market/oracle relationships without a fixed catalog count. -Behavior fixtures fund local vaults to at least 10,000 whole tokens and retain wrapped SOL -backing. This isolates price, ageing and inventory changes from fluctuating live liquidity; -it does not prove that the same trade currently has sufficient mainnet liquidity. +The 2026-09-24 deployment added a rejection, `0x26`. We saw it when a decayed fill dropped +about 5% below the fresh fill, and when the base vault grew about 83x. That is inferred from +replays, not documented. Price, reference band, the 4-byte slot at 16, the multiplier at 20 +and the `0x15` staleness boundary all re-passed on this ELF, so the layout did not move. + +Behavior fixtures top local vaults up toward 10,000 whole tokens, but never past twice the +captured balance, because the program now prices inventory. Wrapped SOL backing is kept. +A vault drained to dust still fails the suite; the fixture does not prove that the same +trade currently has sufficient mainnet liquidity. Run the suite serially with the command at the top of `crates/core/src/tests/goonfi/mod.rs`. `SURFPOOL_TEST_RPC_URL` is optional and defaults to the public mainnet endpoint; a private diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml index a6046c352..2ddad37ff 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml +++ b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml @@ -1,5 +1,5 @@ protocol: GoonFi -version: deployed-438563879 +version: deployed-448984043 account_type: MarketState # The write target here is the market account itself: 2048 bytes owned by the GoonFi program, diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml index 9372dddef..3e620bb4d 100644 --- a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml +++ b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml @@ -1,5 +1,5 @@ protocol: GoonFi -version: deployed-438563879 +version: deployed-448984043 account_type: PriceOracle # The write target of every template here is the market's price-oracle account: 32 bytes owned by diff --git a/crates/core/src/tests/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs index 059a2dbb7..b36f83bc7 100644 --- a/crates/core/src/tests/goonfi/mod.rs +++ b/crates/core/src/tests/goonfi/mod.rs @@ -36,8 +36,8 @@ const ORACLE_PROGRAMDATA: &str = "7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z"; const GOONFI_GLOBAL: &str = "BNrK9LpEn65QA4TyBLVSMdngW3XHj3xLfFPwGdCBv8wV"; const JUPITER_PROGRAM: &str = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; -const CURRENT_DEPLOY_SLOT: u64 = 438_563_879; -const CURRENT_ELF_SHA256: &str = "73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e"; +const CURRENT_DEPLOY_SLOT: u64 = 448_984_043; +const CURRENT_ELF_SHA256: &str = "77241d6c554f3b03eda9dd7cc5a008bbf951c12af79be25827e5314793024a60"; const ORACLE_DEPLOY_SLOT: u64 = 404_369_628; const ORACLE_ELF_SHA256: &str = "0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695"; @@ -46,6 +46,8 @@ const ERROR_STALE_ORACLE: &str = "Custom(21)"; const ERROR_PRICE_OUT_OF_BAND: &str = "Custom(36)"; const ERROR_MIN_AMOUNT_OUT: &str = "Custom(15)"; const ERROR_INSUFFICIENT_LIQUIDITY: &str = "Custom(1)"; +/// Observed, not documented: fires when the decayed fill is ~5% under fresh or inventory jumps. +const ERROR_ADJUSTMENT_LIMIT: &str = "Custom(38)"; /// Oracle layout: both prices are the human pair price times 10^6, independent of mint decimals. /// The freshness slot is 4 bytes; the u32 beside it is the decay-rate multiplier around 10^6 - @@ -231,7 +233,9 @@ fn with_controlled_inventory(mut fork: GoonfiFork) -> GoonfiFork { .and_then(|unit| unit.checked_mul(10_000)) .expect("10,000 whole fixture tokens must fit u64"); let original_amount = token.amount; - token.amount = token.amount.max(minimum_amount); + // Inventory is priced: ~83x base rejects with Custom(38), 2x moves the fill under 0.1%. + let capped_minimum = minimum_amount.min(original_amount.saturating_mul(2)); + token.amount = token.amount.max(capped_minimum); let original_data = vault.data.clone(); spl_token_interface::state::Account::pack(token, &mut vault.data) .expect("pack controlled fixture vault"); @@ -817,11 +821,13 @@ fn stamp_multiplier(data: &mut [u8], multiplier: u32) { .copy_from_slice(&multiplier.to_le_bytes()); } -/// First rejection age in 15..=40 under the given multiplier, asserting fills decay -/// monotonically before it and every rejection carries the staleness error. +/// First staleness rejection age in 15..=40 under the given multiplier. Fills must decay +/// monotonically, and once rejections start they carry the adjustment limit until the +/// staleness error takes over for good. fn rejection_boundary(fork: &GoonfiFork, amount: u64, multiplier: u32) -> u64 { let mut previous = u64::MAX; let mut first_rejection = None; + let mut first_stale = None; for age in 15..=40 { let result = goonfi_run(fork, RunConfig::sell_at_age(amount, age), |oracle| { stamp_multiplier(oracle, multiplier) @@ -830,21 +836,25 @@ fn rejection_boundary(fork: &GoonfiFork, amount: u64, multiplier: u32) -> u64 { Ok(output) => { assert!( first_rejection.is_none(), - "age {age} filled after the window closed at {first_rejection:?}" + "age {age} filled after rejections began at {first_rejection:?}" ); assert!(output <= previous, "decay reversed at age {age}"); previous = output; } + Err(error) if error.contains(ERROR_STALE_ORACLE) => { + first_rejection.get_or_insert(age); + first_stale.get_or_insert(age); + } Err(error) => { assert!( - error.contains(ERROR_STALE_ORACLE), - "age {age}: expected {ERROR_STALE_ORACLE} in: {error}" + first_stale.is_none() && error.contains(ERROR_ADJUSTMENT_LIMIT), + "age {age}: expected {ERROR_ADJUSTMENT_LIMIT} before the {ERROR_STALE_ORACLE} window in: {error}" ); first_rejection.get_or_insert(age); } } } - first_rejection.expect("no rejection up to age 40") + first_stale.expect("no staleness rejection up to age 40") } #[tokio::test]