From 098daaa24813ad0de971b8dce63540b251b3aa48 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 14 Sep 2026 15:11:16 +0300 Subject: [PATCH 1/2] feat(scenarios): add SolFi PMM support --- crates/core/src/scenarios/README.md | 3 + .../scenarios/protocols/solfi/v2/README.md | 279 ++++ .../protocols/solfi/v2/market-overrides.yaml | 183 ++ .../protocols/solfi/v2/oracle-overrides.yaml | 132 ++ .../protocols/solfi/v2/vault-overrides.yaml | 85 + crates/core/src/scenarios/registry.rs | 146 +- crates/core/src/tests/mod.rs | 2 + crates/core/src/tests/solfi/mod.rs | 1474 +++++++++++++++++ crates/mcp/src/surfpool/mod.rs | 16 +- crates/types/src/scenarios.rs | 185 ++- 10 files changed, 2488 insertions(+), 17 deletions(-) create mode 100644 crates/core/src/scenarios/protocols/solfi/v2/README.md create mode 100644 crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml create mode 100644 crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml create mode 100644 crates/core/src/tests/solfi/mod.rs diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 6a53395d5..4c2bd36bb 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -20,6 +20,9 @@ Protocols that are natively supported by Surfpool will have their IDLs included - **Drift v2** - Perp and spot markets, user state, and global state - **Pump v1** - Bonding curve launchpad with curve reserve and global config override templates - **PumpSwap v1** - Constant-product AMM with pool state and global config override templates, including canonical pool derivation for migrated pump.fun coins + - **SolFi v2** – Proprietary market maker (no published IDL, not Anchor), with price, freshness, + directional spread, size-impact and vault-inventory templates. See + [protocols/solfi/v2/README.md](./protocols/solfi/v2/README.md) For custom protocols, an IDL can be registered at runtime using the [`surfnet_registerIdl`](https://docs.surfpool.run/rpc/cheatcodes#surfnet-registeridl) RPC cheatcode. diff --git a/crates/core/src/scenarios/protocols/solfi/v2/README.md b/crates/core/src/scenarios/protocols/solfi/v2/README.md new file mode 100644 index 000000000..51dadca9b --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/README.md @@ -0,0 +1,279 @@ +# SolFi V2 + +A proprietary market maker (PMM), not an AMM. Five templates control its external price, quote +freshness, directional spread, size impact and vault inventory. + +SolFi does not derive its mid from vault ratios. Its external oracle supplies the price, market +splines widen around that price, and the vaults provide inventory and settlement capacity. + +## Template index + +| Template | Overrides | +| --------------------- | ------------------------------------------------ | +| `solfi-price` | the price SolFi uses for swaps | +| `solfi-freshness` | whether SolFi's price is current and usable | +| `solfi-spread` | the extra cost SolFi adds when buying or selling | +| `solfi-size-impact` | how much worse the price gets for larger trades | +| `solfi-vault-balance` | how many tokens SolFi has available for swaps | + +## Number formats + +| You'll see | It means | Example | +| ------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | +| `price_coefficient`, `price_exponent` | coefficient × 10^exponent, adjusted for token decimals | WSOL/USDC $50 at exponent -10 → `500000000` | +| directional curve `y` | scaled by the oracle's widening scale | WSOL/USDC `10000` → 1% with a neutral age multiplier | +| `max_widening` | tenths of a ppm | `100000` = 1%, `10000` = 0.1% | +| size-curve `x` | quote-token smallest units | `1000000000` = 1,000 USDC | +| vault `amount` | that mint's smallest unit | `1000000` = 1 USDC | +| freshness values | offsets from the materialization slot | `0` = this slot, `200` = 200 slots ahead | + +Price conversion is: + +```text +human_price = coefficient × 10^exponent × 10^(base_decimals - quote_decimals) +``` + +The exponent is live state and changed during validation. Set exponent and coefficient together. + +## Picking a market + +Every template now starts with a market picker. Choose one of the two SolFi markets that currently +has meaningful swap liquidity: + +| Choice | Best suited for | +| ----------- | ---------------------------------------------------- | +| WSOL / USDC | SOL price shocks, spread changes and liquidity tests | +| USDT / USDC | Stablecoin depegs and stablecoin liquidity tests | + +The picker supplies the correct market or oracle account to the template automatically. The vault +template asks for both the market and the token vault because each market has one vault for each +side of a swap. Users do not need to copy account addresses into these templates. + +Do not reuse an oracle or vault merely because the token pair looks similar. Use +`fetchBeforeUse: true` so the selected account is forked before its bytes are changed. + +## Two rules that prevent misleading scenarios + +**1. Keep the oracle current while testing price or widening.** A price can be encoded correctly and +still never reach the quote if the oracle has expired. Apply `solfi-freshness` with +`publication_slot: 0` and `validity_horizon: 200` when setup spans multiple slots. If bounded +persistence is installed, prefer `persist: { slots: N }` for the scenario window. On a raw-layout-only +installation, use `persist: true` and stop it when the scenario no longer needs a current quote. + +**2. Persist configuration, not transaction-owned inventory.** Price, freshness and spline settings +are inputs and may be reapplied for the scenario window. A vault balance is state that swaps modify. +Persisting it can undo a swap after every slot and manufacture or erase inventory. + +## Scenario ideas + +### PMM risk-off during a SOL crash + +This is the scenario exposed in Studio's Bento examples. It models a maker that remains available +for small trades but protects itself after SOL falls: it marks SOL at $50, pays 1% less when buying +SOL, and limits its USDC payout inventory to 25 USDC. + +1. On **WSOL / USDC**, use `solfi-price` with exponent `-10` and coefficient `500000000`. +2. Use `solfi-freshness` with publication slot `0` and validity horizon `200` so the new quote is + usable. +3. Use `solfi-spread` with buy-base widening `1000`, sell-base widening `10000`, age multiplier + `1000`, additional widening `0`, and maximum widening `100000`. +4. Use `solfi-vault-balance` on the WSOL/USDC **USDC vault** with amount `25000000`. Do not persist + this transaction-owned balance. + +The deployed-program integration test confirms the complete four-template scenario: a 0.1 SOL sale +still fills near 4.95 USDC, the opposite direction is not widened, a 1 SOL control sale fills with +the original vault, and the same 1 SOL sale fails with error 18 after the 25 USDC limit is applied. + +### SOL price shock + +Use this to test whether a router, trading strategy or lending flow reacts correctly when SolFi's SOL +price moves suddenly. + +1. Choose **WSOL / USDC** in `solfi-price`. +2. Set both price fields to the new price. For example, exponent `-10` and coefficient `500000000` + means $50 per SOL. +3. Add `solfi-freshness` for **WSOL / USDC** with publication slot `0` and validity horizon `200`. +4. Persist both overrides if the test runs for more than one slot. +5. Compare a swap before and after the price change. Also keep a run with the original price as a + control. + +### USDT depeg + +Use this to model USDT trading below or above one dollar and observe route selection or collateral +valuation. + +1. Choose **USDT / USDC** in `solfi-price`. +2. At exponent `-10`, use coefficient `9500000000` for $0.95 or `10500000000` for $1.05. +3. Keep the **USDT / USDC** oracle current with `solfi-freshness`. +4. Compare both swap directions so the test proves the new price is applied reciprocally. + +### Maker becomes cautious in one direction + +Use this to test what happens when SolFi still trades but strongly discourages users from buying one +asset from it. + +1. Choose the market in `solfi-spread`. +2. Increase **Buy-base widening** to make buying WSOL or USDT more expensive, or increase + **Sell-base widening** to make selling it more expensive. +3. Keep the other direction low, set the age multiplier to `1000`, additional widening to `0`, and + set the maximum high enough to allow the requested spread. +4. Keep the matching oracle fresh, then compare equal-notional swaps in both directions. + +### Large orders receive a worse price + +Use this to test order splitting and whether a router moves a large trade to another venue. + +1. Choose a market in `solfi-size-impact`. +2. Give the first size knots small values and later knots progressively larger values. +3. Set the age multiplier to `1000` and additional widening to `0` so only trade size is changing the + result. +4. Run small, medium and large swaps. Require at least one strict deterioration + +### SolFi cannot fill one side of a swap + +Use this to test fallback routing and transaction failure handling when the maker runs out of the +token it must pay. + +1. In `solfi-vault-balance`, choose the WSOL or USDT vault to block users buying the base asset, or + choose the USDC vault to block users selling the base asset. +2. Lower the balance enough that the requested swap cannot be paid. +3. Do not persist the vault balance unless resetting inventory after every transaction is explicitly + part of the test. +4. Confirm the affected swap fails with SolFi error 18 and that the opposite direction or an + alternative venue still works. + +# Recipes + +## Set the PMM price + +```text +template: solfi-price +price_exponent: -10 +price_coefficient: 500000000 # WSOL/USDC at $50 with 9/6 decimals +``` + +Set both fields. Doubling the coefficient doubles base-to-quote output and halves quote-to-base +output, subject to spread and rounding. The price-looking word in the market account is not the +authoritative input, but changing the external oracle is what reprices a fill. + +For a multi-slot scenario, reapply both `solfi-price` and `solfi-freshness` for the required window. +Use bounded persistence when that generic feature is installed; otherwise use `persist: true`. +Repricing only SolFi while leaving another venue unchanged creates a real cross-venue dislocation +suitable for router, arbitrage and liquidation-path testing. Always include an undislocated control leg. + +## Keep the quote current + +```text +template: solfi-freshness +publication_slot: 0 +validity_horizon: 200 +persist: { slots: 200 } # when bounded persistence is available; otherwise use true +``` + +Both inputs are relative offsets even though the account stores XOR-obfuscated absolute slots. +Re-stamp both fields to model a continuously publishing maker. + +An expired SolFi oracle rejects the transaction with error 23. + +## Quote a constant directional spread + +Default WSOL/USDC example, one percent in both directions: + +```text +template: solfi-spread +quote_to_base_curve_y: 10000 +base_to_quote_curve_y: 10000 +age_multiplier_curve_y: 1000 +additional_widening_curve_y: 0 +max_widening: 100000 +``` + +`quote_to_base_curve_y` makes buying the base asset more expensive. `base_to_quote_curve_y` makes +selling it more expensive. Set only one directional property for a risk-off scenario, or both for a +symmetric spread. + +The directional value is market-specific because the oracle contributes a scale: + +```text +final_widening = directional_y × oracle_scale / 1000 +output ≈ oracle_mid × (1 - final_widening / 10000000) +``` + +The verified WSOL/USDC oracle scale is `10000`, so `directional_y: 10000` reaches the 1% clamp. The +verified USDT/USDC scale is `1000`, so its corresponding value is `100000`. Do not copy the same +directional value across markets without reading the oracle scale. + +Set the age multiplier to `1000` and additional widening to `0` when you need the configured spread +to be deterministic. Set `max_widening` at or above the intended result or the clamp will flatten it. + +## Make large trades progressively worse + +Use `solfi-size-impact`. Each direction has eight `y` properties, one for each existing live `x` +breakpoint. Set all eight values for the side being modeled and use non-decreasing values for ordinary +liquidity deterioration. + +```text +template: solfi-size-impact +quote_to_base_y_0: 1000 +quote_to_base_y_1: 1000 +quote_to_base_y_2: 10000 +quote_to_base_y_3: 20000 +quote_to_base_y_4: 40000 +quote_to_base_y_5: 60000 +quote_to_base_y_6: 80000 +quote_to_base_y_7: 100000 +age_multiplier_curve_y: 1000 +additional_widening_curve_y: 0 +max_widening: 100000 +``` + +The template intentionally preserves the market's `x` positions because the operator can change +them live. The `x` axis is raw quote-token notional: + +- quote-to-base uses the raw quote input +- base-to-quote converts the base input to quote notional at the oracle price before lookup. + +SolFi linearly interpolates between adjacent points and uses the nearest endpoint outside the +configured range. The same oracle scale and maximum clamp described under `solfi-spread` still apply. + +## Put the maker into directional risk-off mode + +Use either `solfi-spread` for one constant penalty or `solfi-size-impact` for a penalty that grows +with size: + +```text +# Maker does not want to sell more base +quote_to_base_curve_y: +base_to_quote_curve_y: +``` + +Reverse the two values when the maker does not want to buy more base. The deployed-program tests +require the targeted response to dominate any cross-effect rather than assuming bit-identical output +on the other side. + +## Reduce inventory or make a direction unfillable + +```text +template: solfi-vault-balance +account: +amount: +``` + +The base vault pays quote-to-base swaps, the quote vault pays base-to-quote swaps. Reducing the +payout vault far enough makes that direction reject with SolFi error 18. Vaults also enter nonlinear +inventory policy, so changing the input-side vault can move a quote even though it is not paying out. + +This is not an AMM reserve-price formula. Use `solfi-price` to change the mid. Do not persist a vault +override unless restoring the same inventory after every transaction is deliberately the scenario. + +# Troubleshooting + +| Symptom | Fix | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Price or spread override writes correctly but the swap rejects | Apply `solfi-freshness` for the scenario window. Expiry is checked before pricing | +| `Custom(23)` | The oracle validity horizon is behind the executing slot | +| `Custom(18)` after lowering a vault | The payout vault cannot settle the requested output | +| Constant spread is smaller than requested | Account for `oracle_scale`, neutralize the age/additional curves, and raise `max_widening` | +| Size impact appears at the wrong base amount | Breakpoints are quote notional. Base input is converted at the oracle price first | +| One direction widened instead of the other | Quote-to-base is buying base. Base-to-quote is selling base | +| A vault balance returns after a swap | Remove persistence. Repeated application is undoing transaction-owned state | diff --git a/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml b/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml new file mode 100644 index 000000000..6bbde1aeb --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml @@ -0,0 +1,183 @@ +protocol: SolFi +version: v2 +account_type: Market + +raw_layout: + account_size: 1728 + magic: + offset: 704 + # The initialized marker at the start of the serialized MarketConfig. + bytes: [1, 0, 0, 0, 0, 0, 0, 0] + +tags: + - pmm + - prop-amm + - swap + +constants: + market: + label: SolFi market + description: Choose one of the two SolFi markets that currently has meaningful swap liquidity. + options: + - id: wsol-usdc + label: WSOL / USDC + description: The funded SolFi market for wrapped SOL and USDC. + value: 65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc + metadata: + oracle: 2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou + pair: WSOL/USDC + - id: usdt-usdc + label: USDT / USDC + description: The funded SolFi stablecoin market for USDT and USDC. + value: FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32 + metadata: + oracle: CyCUgmaCYUZxbux3J2svDzxSryVFMtZNPrnMKS41nc4G + pair: USDT/USDC + +templates: + - id: solfi-spread + name: Override Spread + description: Replace SolFi's size and age dependent widening with a deterministic spread + idl_account_name: Market + address: + type: pubkey + value: 65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc + properties: + - path: quote_to_base_curve_y + offset: 792 + encoding: + u64_strided: + count: 8 + stride: 8 + label: Buy-base widening + description: Uniform y value for all eight quote-to-base size knots. + - path: base_to_quote_curve_y + offset: 928 + encoding: + u64_strided: + count: 8 + stride: 8 + label: Sell-base widening + description: Uniform y value for all eight base-to-quote size knots. + - path: age_multiplier_curve_y + offset: 1064 + encoding: + u64_strided: + count: 8 + stride: 8 + label: Oracle-age multiplier + description: Set every knot to 1000 for a neutral, age-independent multiplier. + - path: additional_widening_curve_y + offset: 1200 + encoding: + u64_strided: + count: 8 + stride: 8 + label: Additional widening + description: Set every knot to 0 to remove the secondary dynamic widening term. + - path: max_widening + offset: 1328 + encoding: u64 + label: Maximum effective widening + description: Final clamp in tenths of a ppm; 100000 is 1% and 10000 is 0.1%. + llm_context: | + PRECONDITION - THE ORACLE MUST BE CURRENT. SolFi rejects an expired oracle before pricing, so + an otherwise correct spread can appear to do nothing. For a multi-slot scenario also apply + solfi-freshness with publication_slot 0 and validity_horizon 200. If bounded persistence is + available, use persist: { slots: N } for the scenario window; otherwise use persist: true. + + This template deliberately writes all coupled inputs needed for deterministic widening. Tables + 1 and 2 are direction-specific and keyed by quote-token notional. Quote-to-base uses raw quote + input; base-to-quote converts base input at the oracle price first. Table 3 multiplies them + according to oracle age. Table 4 adds another dynamic term. Writing only one visually + directional table can therefore produce a tiny or timing-dependent response. + + FINAL UNIT: the effective widening is divided by 10000000, so one final unit is 0.1 ppm; + max_widening 100000 is 1%. + + For a constant spread, set all eight knots of each desired direction to one value by using the + corresponding property, set age_multiplier_curve_y to 1000, set + additional_widening_curve_y to 0, and set max_widening to at least the desired final widening. + + The directional curve is scaled by decoded oracle offset 32: + final_widening = curve_y * oracle_scale / 1000 + when the age multiplier is neutral. The verified WSOL/USDC oracle has oracle_scale 10000, so + curve_y 10000 plus max_widening 100000 produces exactly 1%. The verified USDT/USDC oracle has + oracle_scale 1000, so curve_y 100000 produces exactly 1%. + + EXAMPLE - deterministic 1% spread on the selected WSOL/USDC market: + quote_to_base_curve_y: 10000 + base_to_quote_curve_y: 10000 + age_multiplier_curve_y: 1000 + additional_widening_curve_y: 0 + max_widening: 100000 + + This controls configured widening, not inventory. Vault balances are separate live inputs and + may still introduce bounded inventory effects or an insufficient-liquidity refusal. + + DIRECTIONAL RISK-OFF: raise quote_to_base_curve_y when the maker does not want to sell more + base; raise base_to_quote_curve_y when it does not want to buy more base. Set both for a + symmetric spread. These are configuration inputs and may be persisted. + + - id: solfi-size-impact + name: Override Size Impact + description: Shape SolFi widening across the market's eight live trade-size breakpoints + idl_account_name: Market + address: + type: pubkey + value: 65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc + properties: + - { path: quote_to_base_y_0, offset: 792, encoding: u64, label: "Buy-base knot 1", description: "Widening at the first live quote-notional breakpoint; set all eight buy-base knots together." } + - { path: quote_to_base_y_1, offset: 800, encoding: u64, label: "Buy-base knot 2", description: "Widening at the second live quote-notional breakpoint; interpolation begins from adjacent values." } + - { path: quote_to_base_y_2, offset: 808, encoding: u64, label: "Buy-base knot 3", description: "Widening at the third live quote-notional breakpoint." } + - { path: quote_to_base_y_3, offset: 816, encoding: u64, label: "Buy-base knot 4", description: "Widening at the fourth live quote-notional breakpoint." } + - { path: quote_to_base_y_4, offset: 824, encoding: u64, label: "Buy-base knot 5", description: "Widening at the fifth live quote-notional breakpoint." } + - { path: quote_to_base_y_5, offset: 832, encoding: u64, label: "Buy-base knot 6", description: "Widening at the sixth live quote-notional breakpoint." } + - { path: quote_to_base_y_6, offset: 840, encoding: u64, label: "Buy-base knot 7", description: "Widening at the seventh live quote-notional breakpoint." } + - { path: quote_to_base_y_7, offset: 848, encoding: u64, label: "Buy-base knot 8", description: "Widening at and above the final live quote-notional breakpoint." } + - { path: base_to_quote_y_0, offset: 928, encoding: u64, label: "Sell-base knot 1", description: "Widening at the first live quote-notional breakpoint; set all eight sell-base knots together." } + - { path: base_to_quote_y_1, offset: 936, encoding: u64, label: "Sell-base knot 2", description: "Widening at the second live quote-notional breakpoint; interpolation begins from adjacent values." } + - { path: base_to_quote_y_2, offset: 944, encoding: u64, label: "Sell-base knot 3", description: "Widening at the third live quote-notional breakpoint." } + - { path: base_to_quote_y_3, offset: 952, encoding: u64, label: "Sell-base knot 4", description: "Widening at the fourth live quote-notional breakpoint." } + - { path: base_to_quote_y_4, offset: 960, encoding: u64, label: "Sell-base knot 5", description: "Widening at the fifth live quote-notional breakpoint." } + - { path: base_to_quote_y_5, offset: 968, encoding: u64, label: "Sell-base knot 6", description: "Widening at the sixth live quote-notional breakpoint." } + - { path: base_to_quote_y_6, offset: 976, encoding: u64, label: "Sell-base knot 7", description: "Widening at the seventh live quote-notional breakpoint." } + - { path: base_to_quote_y_7, offset: 984, encoding: u64, label: "Sell-base knot 8", description: "Widening at and above the final live quote-notional breakpoint." } + - path: age_multiplier_curve_y + offset: 1064 + encoding: { u64_strided: { count: 8, stride: 8 } } + label: Oracle-age multiplier + description: Set every knot to 1000 for a neutral, age-independent multiplier. + - path: additional_widening_curve_y + offset: 1200 + encoding: { u64_strided: { count: 8, stride: 8 } } + label: Additional widening + description: Set every knot to 0 to isolate trade-size impact. + - path: max_widening + offset: 1328 + encoding: u64 + label: Maximum effective widening + description: Final clamp in tenths of a ppm; 100000 is 1%. + llm_context: | + PRECONDITION - THE ORACLE MUST BE CURRENT. Apply solfi-freshness when setup spans multiple + slots, otherwise expiry can hide a correctly materialized size curve. Prefer + persist: { slots: N } when bounded persistence is available; otherwise use persist: true. + + This is the advanced form of SolFi spread control. It preserves the eight x breakpoints that + are already configured on the live market and replaces their y values. The deployed program + linearly interpolates between adjacent points and clamps to the endpoint outside the populated + range. The x axis is raw quote-token notional: quote-to-base uses the quote input directly, + while base-to-quote converts the base input at the oracle price before lookup. Do not assume + that different markets use the same x values. + + Direction 1 (quote to base / buy base) reads quote_to_base_y_0 through _7. Direction 0 (base + to quote / sell base) reads base_to_quote_y_0 through _7. Values should normally be + non-decreasing when simulating execution that deteriorates with trade size. + + Set age_multiplier_curve_y to 1000 and additional_widening_curve_y to 0 when the goal is to + isolate size impact. The same oracle scale and 1/10000000 final unit described by + solfi-spread apply here. Set max_widening high enough not to flatten the requested curve. + + HOW TO USE: set all eight y values for each direction you care about. A conventional + large-trade deterioration uses non-decreasing values. Do not copy x assumptions between + markets: this template deliberately leaves the operator's live x breakpoints untouched. diff --git a/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml b/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml new file mode 100644 index 000000000..b914bee47 --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml @@ -0,0 +1,132 @@ +protocol: SolFi +version: v2 +account_type: XorPriceOracle + +raw_layout: + account_size: 168 + magic: + offset: 72 + # Two repetitions of the oracle's fixed 0xf0debc9a78563412 word. + bytes: [18, 52, 86, 120, 154, 188, 222, 240, 18, 52, 86, 120, 154, 188, 222, 240] + +tags: + - pmm + - prop-amm + - oracle + +constants: + market: + label: SolFi market + description: Choose one of the two SolFi markets that currently has meaningful swap liquidity. + options: + - id: wsol-usdc + label: WSOL / USDC + description: The funded SolFi market for wrapped SOL and USDC. + value: 2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou + metadata: + market: 65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc + pair: WSOL/USDC + - id: usdt-usdc + label: USDT / USDC + description: The funded SolFi stablecoin market for USDT and USDC. + value: CyCUgmaCYUZxbux3J2svDzxSryVFMtZNPrnMKS41nc4G + metadata: + market: FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32 + pair: USDT/USDC + +templates: + - id: solfi-price + name: Override Price + description: Override the external price coefficient consumed by a SolFi market + idl_account_name: XorPriceOracle + address: + type: pubkey + value: 2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou + properties: + - path: price_exponent + offset: 0 + encoding: + i64_xor: + mask: 11029298117715798783 + label: Decimal exponent + description: Signed base-10 exponent applied to the coefficient; set it explicitly with price_coefficient. + - path: price_coefficient + offset: 8 + encoding: + u64_xor: + mask: 4962160333955141990 + label: Price coefficient + description: >- + Unsigned coefficient before SolFi's fixed XOR encoding. Human price is coefficient times + 10^exponent times 10^(base_decimals - quote_decimals). + llm_context: | + PRECONDITION - SET BOTH PRICE FIELDS. The exponent is live operator state and changed during + validation, so changing only the coefficient can produce a different human price later. + + This is SolFi's authoritative price input. The 1728-byte market account contains a cached, + XOR-looking word at offset 712, but changing that word alone does not move a fill. The program + decodes this 168-byte oracle and prices from its coefficient at decoded offset 8. + + CONVERSION: + human_price = coefficient * 10^exponent * 10^(base_decimals - quote_decimals) + + Set price_exponent and price_coefficient together so a live exponent change cannot alter the + meaning of the scenario. For example, at exponent -10, WSOL/USDC (9/6 decimals) at $50 has + coefficient 500000000, while USDT/USDC (6/6) at $1 has coefficient 10000000000. + + Set fetchBeforeUse: true. The oracle address is embedded in the market account at bytes 24..56; + do not apply a market's price to a different oracle by assumption. + + Price and liveness are independent: changing the coefficient alone reprices immediately, while + an expired validity horizon rejects the swap before the new price can be used. For scenarios + that run for many slots, also apply solfi-freshness. Prefer persist: { slots: N } when bounded + persistence is available; otherwise use persist: true. + + DIRECTION: raising the price increases base-to-quote output and reduces the amount of base paid + by quote-to-base. The deployed-program test confirms the reciprocal response in both directions. + + EXAMPLE - "SOL is worth $50" on the selected 9/6 WSOL/USDC market at exponent -10: + price_exponent: -10 + price_coefficient: 500000000 + + - id: solfi-freshness + name: Override Oracle Freshness + description: Keep SolFi's oracle current or make the market stop quoting + idl_account_name: XorPriceOracle + address: + type: pubkey + value: 2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou + properties: + - path: publication_slot + offset: 16 + encoding: + slot_xor: + lead: 0 + mask: 7407351566499993019 + label: Publication-slot offset + description: Relative to the slot where the override materializes; 0 means published now. + - path: validity_horizon + offset: 40 + encoding: + slot_xor: + lead: 200 + mask: 11053579286710561365 + label: Valid-until offset + description: Relative validity horizon; 200 keeps the oracle valid for 200 slots. + llm_context: | + This is one freshness template, covering both fields SolFi requires for a genuinely current + oracle. Use it as a prerequisite for price and spread scenarios that span multiple slots. + + Both fields are relative slot offsets even though the stored words are absolute slots hidden by + XOR masks. Set publication_slot to 0 and validity_horizon to 200 to emulate a current maker. + For a finite scenario, prefer persist: { slots: N } when bounded persistence is available; + otherwise use persist: true and stop it when the scenario no longer needs a current quote. + + The boundary is inclusive: validity_horizon 0 is valid in the materialization slot, while -1 + is expired and both swap directions revert with SolFi error 23. + + HOW TO USE: + 1. Set publication_slot to 0 + 2. Set validity_horizon to 200 + 3. Reapply both offsets each slot: use persist: { slots: N } when bounded persistence is + available, or persist: true as the raw-layout-compatible fallback diff --git a/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml b/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml new file mode 100644 index 000000000..f4d5a6242 --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml @@ -0,0 +1,85 @@ +protocol: SolFi +version: v2 +account_type: TokenAccount + +raw_layout: + account_size: 165 + magic: + offset: 108 + # Classic SPL Token AccountState::Initialized. + bytes: [1] + +tags: + - pmm + - vault + - inventory + +constants: + market: + label: SolFi market vault + description: Choose the market and the token inventory that SolFi should have available. + options: + - id: wsol-usdc-base + label: WSOL / USDC — WSOL vault + description: Pays out WSOL when a user buys WSOL with USDC. + value: CRo8DBwrmd97DJfAnvCv96tZPL5Mktf2NZy2ZnhDer1A + metadata: + market: 65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc + pair: WSOL/USDC + side: base + - id: wsol-usdc-quote + label: WSOL / USDC — USDC vault + description: Pays out USDC when a user sells WSOL. + value: GhFfLFSprPpfoRaWakPMmJTMJBHuz6C694jYwxy2dAic + metadata: + market: 65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc + pair: WSOL/USDC + side: quote + - id: usdt-usdc-base + label: USDT / USDC — USDT vault + description: Pays out USDT when a user buys USDT with USDC. + value: 5bHD9xdEzJdkVuhs54mGPC9BZgUshqgMg4tqmTwhWggc + metadata: + market: FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32 + pair: USDT/USDC + side: base + - id: usdt-usdc-quote + label: USDT / USDC — USDC vault + description: Pays out USDC when a user sells USDT. + value: ARWaajRJyF6PKQryJ4HLzLBfTWM2qmVQUQVtBjk6PgPc + metadata: + market: FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32 + pair: USDT/USDC + side: quote + +templates: + - id: solfi-vault-balance + name: Override Vault Balance + description: Change one SolFi vault's inventory or make its payout direction unfillable + idl_account_name: TokenAccount + address: + type: pubkey + value: CRo8DBwrmd97DJfAnvCv96tZPL5Mktf2NZy2ZnhDer1A + properties: + - path: amount + offset: 64 + encoding: u64 + label: Vault token balance + description: Raw token amount in the mint's smallest unit. + llm_context: | + PRECONDITION - SELECT A VAULT FROM THE MARKET. The base vault is stored at market bytes + 120..152 and the quote vault at 152..184. Do not apply this to the market or oracle account. + + Apply this to the actual base or quote SPL-token vault referenced by the SolFi market, never to + the 1728-byte market itself. The base vault pays quote-to-base swaps; the quote vault pays + base-to-quote swaps. Reducing a payout vault far enough makes SolFi reject with error 18. + + Vault balances also enter SolFi's nonlinear inventory policy, so changing the input-side vault + can move a quote even though that vault is not paying out. This does not set SolFi's mid-price. + + Do not persist this override across swaps. A persistent vault override can manufacture or erase + inventory after each transaction by undoing transaction-owned balance changes. + + LOWER FOR STRESS TESTS. A moderate reduction can move nonlinear inventory policy; a severe + reduction of the payout vault produces error 18. Raising a wrapped-native vault amount without + matching lamports can create an impossible token account, so lowering is the safe default. diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 11f419e19..55108ff5f 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 SOLFI_ORACLE_OVERRIDES_CONTENT: &str = + include_str!("./protocols/solfi/v2/oracle-overrides.yaml"); +pub const SOLFI_MARKET_OVERRIDES_CONTENT: &str = + include_str!("./protocols/solfi/v2/market-overrides.yaml"); +pub const SOLFI_VAULT_OVERRIDES_CONTENT: &str = + include_str!("./protocols/solfi/v2/vault-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_solfi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -116,6 +123,11 @@ impl TemplateRegistry { ); } + pub fn load_solfi_overrides(&mut self) { + self.load_raw_layout_overrides(SOLFI_ORACLE_OVERRIDES_CONTENT, "solfi-oracle"); + self.load_raw_layout_overrides(SOLFI_MARKET_OVERRIDES_CONTENT, "solfi-market"); + self.load_raw_layout_overrides(SOLFI_VAULT_OVERRIDES_CONTENT, "solfi-vault"); + } pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -494,11 +506,11 @@ 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) + SolFi (5) = 67 assert_eq!( registry.count(), - 62, - "Registry should load 62 templates total" + 67, + "Registry should load 67 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); @@ -569,6 +581,11 @@ mod tests { assert!(registry.contains("pump-amm-pool-state")); assert!(registry.contains("pump-amm-canonical-pool")); assert!(registry.contains("pump-amm-global-config")); + assert!(registry.contains("solfi-price")); + assert!(registry.contains("solfi-freshness")); + assert!(registry.contains("solfi-spread")); + assert!(registry.contains("solfi-size-impact")); + assert!(registry.contains("solfi-vault-balance")); } #[test] @@ -743,8 +760,8 @@ templates: let oracle_templates = registry.by_tags(&[vec!["oracle".to_string()]].concat()); assert_eq!( oracle_templates.len(), - 4, - "Should find 4 oracle templates (Pyth + 3 Kamino Scope)" + 6, + "Should find 6 oracle templates (Pyth + 3 Kamino Scope + 2 SolFi)" ); let rewards_templates = registry.by_tags(&[vec!["rewards".to_string()]].concat()); @@ -1463,4 +1480,123 @@ templates: missing.join("\n ") ); } + + #[test] + fn test_every_solfi_property_has_guidance() { + let registry = TemplateRegistry::new(); + let mut checked = 0; + for id in [ + "solfi-price", + "solfi-freshness", + "solfi-spread", + "solfi-size-impact", + "solfi-vault-balance", + ] { + let template = registry + .get(id) + .unwrap_or_else(|| panic!("missing SolFi template {id}")); + assert!(template.raw_layout.is_some(), "{id} must use a raw layout"); + assert!( + template + .llm_context + .as_deref() + .is_some_and(|context| context.lines().count() >= 6), + "{id} needs substantive LLM guidance" + ); + for property in &template.properties { + assert!( + property + .description + .as_deref() + .is_some_and(|description| !description.trim().is_empty()), + "{id}:{} needs a property description", + property.path + ); + checked += 1; + } + } + assert_eq!(checked, 29, "every shipped SolFi property must be checked"); + } + + #[test] + fn solfi_templates_expose_valid_direct_account_choices() { + let registry = TemplateRegistry::new(); + let cases = [ + ( + "solfi-price", + "2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou", + [ + "2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou", + "CyCUgmaCYUZxbux3J2svDzxSryVFMtZNPrnMKS41nc4G", + ] + .as_slice(), + ), + ( + "solfi-freshness", + "2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou", + [ + "2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou", + "CyCUgmaCYUZxbux3J2svDzxSryVFMtZNPrnMKS41nc4G", + ] + .as_slice(), + ), + ( + "solfi-spread", + "65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc", + [ + "65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc", + "FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32", + ] + .as_slice(), + ), + ( + "solfi-size-impact", + "65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc", + [ + "65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc", + "FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32", + ] + .as_slice(), + ), + ( + "solfi-vault-balance", + "CRo8DBwrmd97DJfAnvCv96tZPL5Mktf2NZy2ZnhDer1A", + [ + "CRo8DBwrmd97DJfAnvCv96tZPL5Mktf2NZy2ZnhDer1A", + "GhFfLFSprPpfoRaWakPMmJTMJBHuz6C694jYwxy2dAic", + "5bHD9xdEzJdkVuhs54mGPC9BZgUshqgMg4tqmTwhWggc", + "ARWaajRJyF6PKQryJ4HLzLBfTWM2qmVQUQVtBjk6PgPc", + ] + .as_slice(), + ), + ]; + + let mut checked = 0; + for (template_id, default_address, expected) in cases { + let template = registry.get(template_id).expect("SolFi template"); + assert_eq!( + template.address, + surfpool_types::AccountAddress::Pubkey(default_address.to_string()), + "{template_id} should default to the first catalog entry" + ); + let options = &template + .constants + .get("market") + .expect("Tessera-style market options") + .options; + assert_eq!(options.len(), expected.len(), "{template_id}"); + assert_eq!(options[0].value, default_address, "{template_id}"); + + for (option, expected_address) in options.iter().zip(expected) { + assert_eq!( + Pubkey::from_str(&option.value).expect("valid selectable pubkey"), + Pubkey::from_str(expected_address).expect("valid expected pubkey"), + "{template_id}:{}", + option.id + ); + checked += 1; + } + } + assert_eq!(checked, 12, "every selectable SolFi target must be checked"); + } } diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index b2dd37925..e6f792567 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -6,3 +6,5 @@ pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; pub mod simnet_events; +#[cfg(feature = "integration-tests")] +pub mod solfi; diff --git a/crates/core/src/tests/solfi/mod.rs b/crates/core/src/tests/solfi/mod.rs new file mode 100644 index 000000000..ce6f6e6a1 --- /dev/null +++ b/crates/core/src/tests/solfi/mod.rs @@ -0,0 +1,1474 @@ +//! SolFi V2 raw-layout and deployed-program tests. +//! +//! These deliberately drive the shipped templates through `RawLayout::materialize` before replaying +//! the current deployed program. SolFi publishes no IDL, and visually plausible offsets are not +//! evidence that a field reaches pricing. + +use std::{collections::HashMap, sync::Arc}; + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{GetAccountResult, remote::SurfnetRemoteClient}, +}; + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; +const PROGRAM: &str = "SV2EYYJyRz2YhfXwXnhNAevDEui5Q6yrfyo13WtupPF"; +const PROGRAMDATA: &str = "H6M3jMJCednoAr7BR9P6versKQmbo5kV3oi8R5JsWNKz"; +const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + +const PRICE_MASK: u64 = 0x44dd_2288_77ee_1166; +const EXPONENT_MASK: u64 = 0x990f_f033_cc55_aaff; +const PUBLICATION_MASK: u64 = 0x66cc_3300_ffaa_55bb; +const SCALE_MASK: u64 = 0x4488_dd22_ee11_7799; + +#[derive(Clone, Copy)] +struct MarketDef { + market: &'static str, + oracle: &'static str, + cfg: &'static str, + base_vault: &'static str, + quote_vault: &'static str, + base_mint: &'static str, + quote_mint: &'static str, + base_trade: u64, + quote_trade: u64, +} + +const MARKETS: [MarketDef; 2] = [ + MarketDef { + market: "65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc", + oracle: "2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou", + cfg: "FmxXDSR9WvpJTCh738D1LEDuhMoA8geCtZgHb3isy7Dp", + base_vault: "CRo8DBwrmd97DJfAnvCv96tZPL5Mktf2NZy2ZnhDer1A", + quote_vault: "GhFfLFSprPpfoRaWakPMmJTMJBHuz6C694jYwxy2dAic", + base_mint: "So11111111111111111111111111111111111111112", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + base_trade: 1_000_000_000, + quote_trade: 100_000_000, + }, + MarketDef { + market: "FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32", + oracle: "CyCUgmaCYUZxbux3J2svDzxSryVFMtZNPrnMKS41nc4G", + cfg: "QoFvFhDZg9TaZEi4SsasWpH5xXzk3zBqfRyicGexfNQ", + base_vault: "5bHD9xdEzJdkVuhs54mGPC9BZgUshqgMg4tqmTwhWggc", + quote_vault: "ARWaajRJyF6PKQryJ4HLzLBfTWM2qmVQUQVtBjk6PgPc", + base_mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + base_trade: 1_000_000_000, + quote_trade: 1_000_000_000, + }, +]; + +const INITIALIZED_LAYOUTS: [(&str, &str); 10] = [ + ( + "FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32", + "CyCUgmaCYUZxbux3J2svDzxSryVFMtZNPrnMKS41nc4G", + ), + ( + "65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc", + "2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou", + ), + ( + "BjBHvbqgQCRmvZ6u3VzGrHn3QZ1NfmMRujoqjeaK6fLT", + "7GELMFc4yK1jBMZHsoPhHYYhKTPgmpK7doFPVx62Kiy1", + ), + ( + "394qso4LKsjjHchKu6S8A1Nt9iEryNPyU5cFN79RTVpY", + "AwppY62pZj9WApQ4r8ezKY69nadWjmEiASwpU6WCY1Ni", + ), + ( + "72ekvC4bc94sg8BVvqNYHDugFatPbxbyYFQDaL9EPupi", + "DNTdSZXtejmGQkR4RuwgkCnrmbUwUeSs4vRKfEfhc9jZ", + ), + ( + "HH2HgSHxgFjyzUd2dv9k68D5RQbBZWtpYan1gx8kjUi5", + "4G3szXUscfmzPJgzrCwZ5ecu9cQNeXjFRmimMmyPANsA", + ), + ( + "8BrwYAr1K11sG8GvM8vUFAw45Mm1mLCuTd4ynhNMYjRC", + "4LSXFMNRw8k3h7c95f7kPM8aphUswqcgvi8pTmfeL2T1", + ), + ( + "2sp6rCc4VaXJ5qCbrPukpQVjZVZey42pj7QkynYNDdw3", + "By9zHEbZJvYrBws27SqPXggfSAH3fjnJcdxKgdogyXUm", + ), + ( + "ErP5XNqqLXN99hoa4JnHB199rcetMjMdEoJwnphwv7sn", + "5PE4Z3LEzeUW6UYqGmpmswPwo7TVu3R7chXbCBSBaCZ5", + ), + ( + "5Q6oe47U9WxMhvnEjpi6AnZZPMBcatcWKUTfLkguPEiG", + "6LRUvVthoRGUSfJMqewZtFRmp2fK96xoUn6AyahqxxBw", + ), +]; + +const UNINITIALIZED_MARKETS: [&str; 9] = [ + "2kfQuYG2FVZL2RqqKEttcdadbPWP4c7b6AFQztNcBWyV", + "2Q6S8p9iZNzMvpTemiC56HqCJ3F3szNoyRkvqEKfCanY", + "GxZwsApah3Bsgg14dG7MUtnPCQbGiDqFEwyWYZvDxn6Y", + "Bnwc3wzE8PYYvgtbriRn9RpRnDH1TvJJthJVacrbgiD7", + "AZEKRYWew6zAyoksytTeBFJRHyYdwycPMBn1P2QgfDpQ", + "2e25gRiddjn968aXrLt1oZw3BZ4fYD5D8mCv7uKxu1yL", + "7TKsqWxU9QkPYVLdjjR1V67ky3FnYogjntUpNLexib4E", + "BmVBqFL8LD2KiBsDE8fWXLZ2MWVgPR1qor55MCimriGR", + "HYKRMKiXfs1CedDsUHNyaVmEyuw7gj3E3uY6gJgUeMr6", +]; + +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 keys: Vec = addresses + .iter() + .map(|a| Pubkey::from_str_const(a)) + .collect(); + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(&keys, CommitmentConfig::confirmed()) + .await + { + Ok(v) => break v, + Err(e) => { + attempt += 1; + assert!(attempt < 5, "fetch {addresses:?}: {e}"); + tokio::time::sleep(std::time::Duration::from_millis(750 * attempt)).await; + } + } + }; + results + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account, + GetAccountResult::None(_) => panic!("{address} no longer exists"), + }) + .collect() +} + +#[derive(Clone)] +struct SolfiFork { + def: MarketDef, + elf: Vec, + market: Account, + oracle: Account, + cfg: Account, + base_vault: Account, + quote_vault: Account, + base_mint: Account, + quote_mint: Account, + slot: u64, +} + +async fn forks() -> Arc> { + static CACHE: tokio::sync::OnceCell>> = tokio::sync::OnceCell::const_new(); + CACHE + .get_or_init(|| async { + let programdata = fetch(&[PROGRAMDATA]).await.remove(0); + assert!( + programdata.data.len() > 240_000, + "programdata is unexpectedly short" + ); + let elf = programdata.data[45..].to_vec(); + let mut out = Vec::new(); + for def in MARKETS { + let a = fetch(&[ + def.market, + def.oracle, + def.cfg, + def.base_vault, + def.quote_vault, + def.base_mint, + def.quote_mint, + ]) + .await; + assert_eq!(a[0].data.len(), 1728); + assert_eq!(a[1].data.len(), 168); + assert_eq!(a[2].data.len(), 1_048_576); + assert_eq!( + Pubkey::new_from_array(a[0].data[24..56].try_into().unwrap()), + Pubkey::from_str_const(def.oracle) + ); + assert_eq!( + Pubkey::new_from_array(a[0].data[120..152].try_into().unwrap()), + Pubkey::from_str_const(def.base_vault) + ); + assert_eq!( + Pubkey::new_from_array(a[0].data[152..184].try_into().unwrap()), + Pubkey::from_str_const(def.quote_vault) + ); + let slot = u64::from_le_bytes(a[0].data[1352..1360].try_into().unwrap()); + out.push(SolfiFork { + def, + elf: elf.clone(), + market: a[0].clone(), + oracle: a[1].clone(), + cfg: a[2].clone(), + base_vault: a[3].clone(), + quote_vault: a[4].clone(), + base_mint: a[5].clone(), + quote_mint: a[6].clone(), + slot, + }); + } + Arc::new(out) + }) + .await + .clone() +} + +fn apply_raw(id: &str, data: &[u8], values: &[(&str, serde_json::Value)], slot: u64) -> Vec { + let registry = TemplateRegistry::new(); + let t = registry.get(id).unwrap_or_else(|| panic!("missing {id}")); + let map = values + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect::>(); + t.raw_layout + .as_ref() + .expect("raw layout") + .materialize(data, &t.properties, &map, slot) + .unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn diff_indices(a: &[u8], b: &[u8]) -> Vec { + a.iter() + .zip(b) + .enumerate() + .filter_map(|(i, (a, b))| (a != b).then_some(i)) + .collect() +} + +fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = vec![0u8; 165]; + data[..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 amount(data: &[u8]) -> u64 { + u64::from_le_bytes(data[64..72].try_into().unwrap()) +} + +fn replay( + fork: &SolfiFork, + amount_in: u64, + direction: u8, + market: Vec, + oracle: Vec, + base_vault: Vec, + quote_vault: Vec, +) -> Result { + use litesvm::LiteSVM; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let program = Pubkey::from_str_const(PROGRAM); + let token_program = Pubkey::from_str_const(TOKEN_PROGRAM); + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program, &fork.elf) + .map_err(|e| format!("add_program: {e:?}"))?; + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = fork.slot; + svm.set_sysvar(&clock); + + let mut put = |key: &str, mut account: Account, data: Vec| -> Result<(), String> { + account.data = data; + svm.set_account(Pubkey::from_str_const(key), account) + .map_err(|e| format!("set {key}: {e:?}")) + }; + put(fork.def.market, fork.market.clone(), market)?; + put(fork.def.oracle, fork.oracle.clone(), oracle)?; + put(fork.def.cfg, fork.cfg.clone(), fork.cfg.data.clone())?; + put(fork.def.base_vault, fork.base_vault.clone(), base_vault)?; + put(fork.def.quote_vault, fork.quote_vault.clone(), quote_vault)?; + put( + fork.def.base_mint, + fork.base_mint.clone(), + fork.base_mint.data.clone(), + )?; + put( + fork.def.quote_mint, + fork.quote_mint.clone(), + fork.quote_mint.data.clone(), + )?; + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 20_000_000_000) + .map_err(|e| format!("airdrop: {e:?}"))?; + let user_base = Pubkey::new_unique(); + let user_quote = Pubkey::new_unique(); + let base_key = Pubkey::from_str_const(fork.def.base_mint); + let quote_key = Pubkey::from_str_const(fork.def.quote_mint); + let (base_amount, quote_amount) = if direction == 0 { + (amount_in.saturating_mul(2), 0) + } else { + (0, amount_in.saturating_mul(2)) + }; + for (key, mint, balance) in [ + (user_base, base_key, base_amount), + (user_quote, quote_key, quote_amount), + ] { + svm.set_account( + key, + Account { + lamports: balance.saturating_add(2_039_280), + data: token_account(&mint, &taker.pubkey(), balance), + owner: token_program, + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|e| format!("user token account: {e:?}"))?; + } + + let mut data = vec![7u8]; + data.extend_from_slice(&amount_in.to_le_bytes()); + data.extend_from_slice(&1u64.to_le_bytes()); + data.push(direction); + let ix = Instruction { + program_id: program, + accounts: vec![ + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(Pubkey::from_str_const(fork.def.market), false), + AccountMeta::new_readonly(Pubkey::from_str_const(fork.def.oracle), false), + AccountMeta::new_readonly(Pubkey::from_str_const(fork.def.cfg), false), + AccountMeta::new(Pubkey::from_str_const(fork.def.base_vault), false), + AccountMeta::new(Pubkey::from_str_const(fork.def.quote_vault), false), + AccountMeta::new(user_base, false), + AccountMeta::new(user_quote, false), + AccountMeta::new(base_key, false), + AccountMeta::new(quote_key, false), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly( + Pubkey::from_str_const("Sysvar1nstructions1111111111111111111111111"), + false, + ), + ], + data, + }; + let mut msg = solana_message::Message::new(&[ix], Some(&taker.pubkey())); + msg.recent_blockhash = svm.latest_blockhash(); + let mut tx = Transaction::new_unsigned(msg); + tx.signatures = vec![ + solana_signature::Signature::default(); + tx.message.header.num_required_signatures as usize + ]; + tx.signatures[0] = taker.sign_message(&tx.message.serialize()); + svm.send_transaction(tx) + .map_err(|e| format!("{:?}", e.err))?; + let dst = if direction == 0 { + user_quote + } else { + user_base + }; + Ok(amount(&svm.get_account(&dst).expect("destination").data)) +} + +fn run( + fork: &SolfiFork, + amount_in: u64, + direction: u8, + market: Vec, + oracle: Vec, +) -> Result { + replay( + fork, + amount_in, + direction, + market, + oracle, + fork.base_vault.data.clone(), + fork.quote_vault.data.clone(), + ) +} + +#[tokio::test] +async fn solfi_raw_layouts_cover_every_initialized_market_and_reject_every_sibling() { + let registry = TemplateRegistry::new(); + let market_templates = ["solfi-spread", "solfi-size-impact"]; + let oracle_templates = ["solfi-price", "solfi-freshness"]; + + let initialized_addresses = INITIALIZED_LAYOUTS + .iter() + .flat_map(|(market, oracle)| [*market, *oracle]) + .collect::>(); + let initialized = fetch(&initialized_addresses).await; + let mut checked = 0; + for (accounts, (market_address, oracle_address)) in + initialized.chunks_exact(2).zip(INITIALIZED_LAYOUTS) + { + let market = &accounts[0].data; + let oracle = &accounts[1].data; + assert_eq!(market.len(), 1728, "{market_address}"); + assert_eq!(oracle.len(), 168, "{oracle_address}"); + assert_eq!( + Pubkey::new_from_array(market[24..56].try_into().unwrap()), + Pubkey::from_str_const(oracle_address), + "{market_address} no longer embeds the expected oracle" + ); + + for id in market_templates { + let template = registry.get(id).unwrap(); + template + .raw_layout + .as_ref() + .unwrap() + .guard(market) + .unwrap_or_else(|e| panic!("{id} rejected {market_address}: {e}")); + assert_eq!( + apply_raw(id, market, &[], 0), + market.to_vec(), + "{id} must round-trip {market_address}" + ); + } + for id in oracle_templates { + let template = registry.get(id).unwrap(); + template + .raw_layout + .as_ref() + .unwrap() + .guard(oracle) + .unwrap_or_else(|e| panic!("{id} rejected {oracle_address}: {e}")); + assert_eq!( + apply_raw(id, oracle, &[], 0), + oracle.to_vec(), + "{id} must round-trip {oracle_address}" + ); + } + + let changed_market = apply_raw( + "solfi-spread", + market, + &[("quote_to_base_curve_y", serde_json::json!(1234))], + 0, + ); + assert!( + diff_indices(market, &changed_market) + .iter() + .all(|i| (792..856).contains(i)), + "{market_address} escaped the directional spline write set" + ); + let changed_oracle = apply_raw( + "solfi-price", + oracle, + &[("price_coefficient", serde_json::json!(12_345_678u64))], + 0, + ); + assert!( + diff_indices(oracle, &changed_oracle) + .iter() + .all(|i| (8..16).contains(i)), + "{oracle_address} escaped the price coefficient write set" + ); + checked += 1; + } + assert_eq!(checked, 10, "every initialized market must be exercised"); + + let vault_addresses = initialized + .chunks_exact(2) + .flat_map(|accounts| { + let market = &accounts[0].data; + [ + Pubkey::new_from_array(market[120..152].try_into().unwrap()).to_string(), + Pubkey::new_from_array(market[152..184].try_into().unwrap()).to_string(), + ] + }) + .collect::>(); + let vault_address_refs = vault_addresses + .iter() + .map(String::as_str) + .collect::>(); + let vaults = fetch(&vault_address_refs).await; + let vault_template = registry.get("solfi-vault-balance").unwrap(); + let mut supported_vaults = 0; + for (vault, address) in vaults.iter().zip(&vault_addresses) { + let guard = vault_template + .raw_layout + .as_ref() + .unwrap() + .guard(&vault.data); + if vault.data.len() == 165 && vault.data[108] == 1 { + guard.unwrap_or_else(|e| panic!("vault guard rejected {address}: {e}")); + assert_eq!( + apply_raw("solfi-vault-balance", &vault.data, &[], 0), + vault.data.clone(), + "vault template must round-trip {address}" + ); + let changed = apply_raw( + "solfi-vault-balance", + &vault.data, + &[("amount", serde_json::json!(123u64))], + 0, + ); + assert!( + diff_indices(&vault.data, &changed) + .iter() + .all(|i| (64..72).contains(i)), + "{address} escaped the token amount write set" + ); + supported_vaults += 1; + } else { + assert!( + guard.is_err(), + "vault guard admitted unsupported token layout {address}" + ); + } + } + assert!( + supported_vaults >= 18, + "expected both vault layouts for almost every initialized market, got {supported_vaults}" + ); + + let siblings = fetch(&UNINITIALIZED_MARKETS).await; + let mut rejected = 0; + for (account, address) in siblings.iter().zip(UNINITIALIZED_MARKETS) { + assert_eq!(account.data.len(), 1728, "{address}"); + for id in market_templates { + let template = registry.get(id).unwrap(); + assert!( + template + .raw_layout + .as_ref() + .unwrap() + .guard(&account.data) + .is_err(), + "{id} admitted uninitialized sibling {address}" + ); + } + rejected += 1; + } + assert_eq!(rejected, 9, "every uninitialized sibling must be rejected"); +} + +#[tokio::test] +async fn solfi_templates_write_only_proven_bytes_on_both_replay_fixtures() { + let forks = forks().await; + let mut checked = 0; + for fork in forks.iter() { + assert_eq!( + apply_raw("solfi-price", &fork.oracle.data, &[], fork.slot), + fork.oracle.data, + "oracle must round-trip unchanged" + ); + assert_eq!( + apply_raw("solfi-spread", &fork.market.data, &[], fork.slot), + fork.market.data, + "market must round-trip unchanged" + ); + assert_eq!( + apply_raw("solfi-size-impact", &fork.market.data, &[], fork.slot), + fork.market.data, + "size-impact template must round-trip unchanged" + ); + for vault in [&fork.base_vault.data, &fork.quote_vault.data] { + assert_eq!( + apply_raw("solfi-vault-balance", vault, &[], fork.slot), + vault.to_vec(), + "vault template must round-trip unchanged" + ); + let replacement = amount(vault) / 2; + let changed = apply_raw( + "solfi-vault-balance", + vault, + &[("amount", serde_json::json!(replacement))], + fork.slot, + ); + assert!( + diff_indices(vault, &changed) + .iter() + .all(|i| (64..72).contains(i)), + "vault balance escaped token-account amount bytes" + ); + assert_eq!(amount(&changed), replacement); + } + + let price = apply_raw( + "solfi-price", + &fork.oracle.data, + &[ + ("price_exponent", serde_json::json!(-10)), + ("price_coefficient", serde_json::json!(12_345_678u64)), + ], + fork.slot, + ); + assert!( + diff_indices(&fork.oracle.data, &price) + .iter() + .all(|i| (0..16).contains(i)) + ); + assert_eq!( + (u64::from_le_bytes(price[0..8].try_into().unwrap()) ^ EXPONENT_MASK) as i64, + -10 + ); + assert_eq!( + u64::from_le_bytes(price[8..16].try_into().unwrap()) ^ PRICE_MASK, + 12_345_678 + ); + + let fresh = apply_raw( + "solfi-freshness", + &fork.oracle.data, + &[ + ("publication_slot", serde_json::json!(0)), + ("validity_horizon", serde_json::json!(200)), + ], + fork.slot, + ); + assert!( + diff_indices(&fork.oracle.data, &fresh) + .iter() + .all(|i| (16..24).contains(i) || (40..48).contains(i)) + ); + assert_eq!( + u64::from_le_bytes(fresh[16..24].try_into().unwrap()) ^ PUBLICATION_MASK, + fork.slot + ); + assert_eq!( + u64::from_le_bytes(fresh[40..48].try_into().unwrap()) ^ 0x9966_33cc_00ff_aa55, + fork.slot + 200 + ); + + let spread = apply_raw( + "solfi-spread", + &fork.market.data, + &[ + ("quote_to_base_curve_y", serde_json::json!(1234)), + ("base_to_quote_curve_y", serde_json::json!(2345)), + ("age_multiplier_curve_y", serde_json::json!(1000)), + ("additional_widening_curve_y", serde_json::json!(0)), + ("max_widening", serde_json::json!(100000)), + ], + fork.slot, + ); + let allowed = [792..856, 928..992, 1064..1128, 1200..1264, 1328..1336]; + assert!( + diff_indices(&fork.market.data, &spread) + .iter() + .all(|i| allowed.iter().any(|r| r.contains(i))), + "{} escaped its write set", + fork.def.market + ); + for (offset, expected) in [(792, 1234), (928, 2345), (1064, 1000), (1200, 0)] { + for i in 0..8 { + assert_eq!( + u64::from_le_bytes( + spread[offset + i * 8..offset + (i + 1) * 8] + .try_into() + .unwrap() + ), + expected, + "{} offset {}", + fork.def.market, + offset + i * 8 + ); + } + } + assert_eq!( + u64::from_le_bytes(spread[1328..1336].try_into().unwrap()), + 100_000 + ); + + let shaped = size_impact_market(fork, 10_000, 100_000); + assert!( + diff_indices(&fork.market.data, &shaped) + .iter() + .all(|i| allowed.iter().any(|r| r.contains(i))), + "{} size-impact escaped its proven write set", + fork.def.market + ); + assert_eq!( + &shaped[728..792], + &fork.market.data[728..792], + "quote-to-base x knots must remain live" + ); + assert_eq!( + &shaped[864..928], + &fork.market.data[864..928], + "base-to-quote x knots must remain live" + ); + checked += 1; + } + assert_eq!( + checked, 2, + "both complete replay fixtures must be exercised" + ); +} + +#[tokio::test] +async fn solfi_raw_guards_reject_corrupted_type_markers() { + let fork = &forks().await[0]; + let registry = TemplateRegistry::new(); + + let price = registry.get("solfi-price").expect("price template"); + let mut bad_oracle = fork.oracle.data.clone(); + bad_oracle[72] ^= 1; + assert!( + price + .raw_layout + .as_ref() + .unwrap() + .guard(&bad_oracle) + .is_err(), + "oracle magic is the protection against a wrong 168-byte account" + ); + + let spread = registry.get("solfi-spread").expect("spread template"); + let mut bad_market = fork.market.data.clone(); + bad_market[704] ^= 1; + assert!( + spread + .raw_layout + .as_ref() + .unwrap() + .guard(&bad_market) + .is_err(), + "MarketConfig initialized marker must be part of the guard" + ); + + let vault = registry.get("solfi-vault-balance").expect("vault template"); + assert!( + vault + .raw_layout + .as_ref() + .unwrap() + .guard(&vec![0; 164]) + .is_err(), + "vault layout must reject non-token-account sizes" + ); + let mut wrong_state = fork.base_vault.data.clone(); + wrong_state[108] = 0; + assert!( + vault + .raw_layout + .as_ref() + .unwrap() + .guard(&wrong_state) + .is_err(), + "vault layout must reject a token account that is not initialized" + ); +} + +#[tokio::test] +async fn solfi_price_dislocation_scenario_moves_both_directions_reciprocally() { + let fork = &forks().await[1]; + let coefficient = u64::from_le_bytes(fork.oracle.data[8..16].try_into().unwrap()) ^ PRICE_MASK; + let exponent = + (u64::from_le_bytes(fork.oracle.data[0..8].try_into().unwrap()) ^ EXPONENT_MASK) as i64; + for direction in 0..=1 { + let amount_in = if direction == 0 { + fork.def.base_trade + } else { + fork.def.quote_trade + }; + let baseline = run( + fork, + amount_in, + direction, + fork.market.data.clone(), + fork.oracle.data.clone(), + ) + .expect("baseline"); + let doubled_oracle = apply_raw( + "solfi-price", + &fork.oracle.data, + &[("price_coefficient", serde_json::json!(coefficient * 2))], + fork.slot, + ); + let doubled = run( + fork, + amount_in, + direction, + fork.market.data.clone(), + doubled_oracle, + ) + .expect("doubled price"); + let ratio = doubled as f64 / baseline as f64; + let expected = if direction == 0 { 2.0 } else { 0.5 }; + assert!( + (ratio - expected).abs() < 0.0001, + "direction {direction}: {ratio}" + ); + + let equivalent = apply_raw( + "solfi-price", + &fork.oracle.data, + &[ + ("price_exponent", serde_json::json!(exponent - 1)), + ("price_coefficient", serde_json::json!(coefficient * 10)), + ], + fork.slot, + ); + let same = run( + fork, + amount_in, + direction, + fork.market.data.clone(), + equivalent, + ) + .expect("equivalent exponent/coefficient pair"); + assert_eq!(same, baseline, "exponent/coefficient formula drifted"); + } +} + +fn deterministic_spread(fork: &SolfiFork, final_units: u64) -> Vec { + directional_spread(fork, final_units, final_units, final_units) +} + +fn directional_spread( + fork: &SolfiFork, + quote_to_base_units: u64, + base_to_quote_units: u64, + max_widening: u64, +) -> Vec { + let scale = u64::from_le_bytes(fork.oracle.data[32..40].try_into().unwrap()) ^ SCALE_MASK; + let curve = |final_units: u64| { + assert_eq!( + (final_units * 1000) % scale, + 0, + "test target must divide exactly" + ); + final_units * 1000 / scale + }; + apply_raw( + "solfi-spread", + &fork.market.data, + &[ + ( + "quote_to_base_curve_y", + serde_json::json!(curve(quote_to_base_units)), + ), + ( + "base_to_quote_curve_y", + serde_json::json!(curve(base_to_quote_units)), + ), + ("age_multiplier_curve_y", serde_json::json!(1000)), + ("additional_widening_curve_y", serde_json::json!(0)), + ("max_widening", serde_json::json!(max_widening)), + ], + fork.slot, + ) +} + +const QUOTE_TO_BASE_Y: [&str; 8] = [ + "quote_to_base_y_0", + "quote_to_base_y_1", + "quote_to_base_y_2", + "quote_to_base_y_3", + "quote_to_base_y_4", + "quote_to_base_y_5", + "quote_to_base_y_6", + "quote_to_base_y_7", +]; +const BASE_TO_QUOTE_Y: [&str; 8] = [ + "base_to_quote_y_0", + "base_to_quote_y_1", + "base_to_quote_y_2", + "base_to_quote_y_3", + "base_to_quote_y_4", + "base_to_quote_y_5", + "base_to_quote_y_6", + "base_to_quote_y_7", +]; + +fn size_impact_market(fork: &SolfiFork, tight_units: u64, wide_units: u64) -> Vec { + let scale = u64::from_le_bytes(fork.oracle.data[32..40].try_into().unwrap()) ^ SCALE_MASK; + let curve = |units: u64| { + assert_eq!((units * 1000) % scale, 0); + units * 1000 / scale + }; + let tight = curve(tight_units); + let wide = curve(wide_units); + let mut values = Vec::new(); + for paths in ["E_TO_BASE_Y, &BASE_TO_QUOTE_Y] { + for (index, path) in paths.iter().enumerate() { + values.push(( + *path, + serde_json::json!(if index < 2 { tight } else { wide }), + )); + } + } + values.extend([ + ("age_multiplier_curve_y", serde_json::json!(1000)), + ("additional_widening_curve_y", serde_json::json!(0)), + ("max_widening", serde_json::json!(wide_units)), + ]); + apply_raw("solfi-size-impact", &fork.market.data, &values, fork.slot) +} + +#[tokio::test] +async fn solfi_spread_template_has_the_derived_absolute_unit_on_both_markets() { + let forks = forks().await; + let mut checked = 0; + for fork in forks.iter() { + let oracle = apply_raw( + "solfi-freshness", + &fork.oracle.data, + &[ + ("publication_slot", serde_json::json!(0)), + ("validity_horizon", serde_json::json!(200)), + ], + fork.slot, + ); + for (direction, amount_in) in [(0, fork.def.base_trade), (1, fork.def.quote_trade)] { + let tight = run( + fork, + amount_in, + direction, + deterministic_spread(fork, 10_000), + oracle.clone(), + ) + .expect("0.1% spread"); + let wide = run( + fork, + amount_in, + direction, + deterministic_spread(fork, 100_000), + oracle.clone(), + ) + .expect("1% spread"); + assert!( + wide < tight, + "{} direction {direction} did not widen", + fork.def.market + ); + let incremental = (tight - wide) as f64 / amount_in as f64; + let exponent = + (u64::from_le_bytes(oracle[0..8].try_into().unwrap()) ^ EXPONENT_MASK) as i64; + let raw_price = (u64::from_le_bytes(oracle[8..16].try_into().unwrap()) ^ PRICE_MASK) + as f64 + * 10f64.powi(exponent as i32); + let expected = if direction == 0 { + raw_price * 0.009 + } else { + 0.009 / raw_price + }; + assert!( + (incremental - expected).abs() < expected * 0.002 + 1e-9, + "{} direction {direction}: incremental={incremental}, expected={expected}", + fork.def.market + ); + checked += 1; + } + } + assert_eq!(checked, 4, "both directions on both replay fixtures"); +} + +#[tokio::test] +async fn solfi_directional_risk_off_scenario_is_isolated_to_the_target_side() { + let forks = forks().await; + let mut checked = 0; + for fork in forks.iter() { + let oracle = apply_raw( + "solfi-freshness", + &fork.oracle.data, + &[ + ("publication_slot", serde_json::json!(0)), + ("validity_horizon", serde_json::json!(200)), + ], + fork.slot, + ); + let tight = directional_spread(fork, 10_000, 10_000, 100_000); + let controls = [ + run(fork, fork.def.base_trade, 0, tight.clone(), oracle.clone()) + .expect("tight base-to-quote control"), + run(fork, fork.def.quote_trade, 1, tight, oracle.clone()) + .expect("tight quote-to-base control"), + ]; + + for (target_direction, market) in [ + (1usize, directional_spread(fork, 100_000, 10_000, 100_000)), + (0usize, directional_spread(fork, 10_000, 100_000, 100_000)), + ] { + let other_direction = 1 - target_direction; + let target_amount = if target_direction == 0 { + fork.def.base_trade + } else { + fork.def.quote_trade + }; + let other_amount = if other_direction == 0 { + fork.def.base_trade + } else { + fork.def.quote_trade + }; + let targeted = run( + fork, + target_amount, + target_direction as u8, + market.clone(), + oracle.clone(), + ) + .expect("targeted risk-off quote"); + let untargeted = run( + fork, + other_amount, + other_direction as u8, + market, + oracle.clone(), + ) + .expect("untargeted quote"); + let targeted_delta = controls[target_direction].saturating_sub(targeted); + let cross_delta = controls[other_direction].abs_diff(untargeted); + assert!( + targeted_delta * 1_000 > controls[target_direction] * 8, + "{} direction {target_direction} moved by less than 0.8%", + fork.def.market + ); + assert!( + (cross_delta as u128) * 20 < targeted_delta as u128, + "{} target delta {targeted_delta}, cross delta {cross_delta}", + fork.def.market + ); + checked += 1; + } + } + assert_eq!(checked, 4, "both risk directions on both markets"); +} + +/// This deliberately combines four shipped templates. The small sell proves the maker remains a +/// real PMM rather than simply being switched off, the directional control proves the configured +/// risk premium lands on the intended side, and the large sell must fail because the bounded quote +/// inventory cannot settle it. +#[tokio::test] +async fn solfi_bento_risk_off_scenario_executes_against_the_deployed_program() { + use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + + use crate::surfnet::svm::SurfnetSvm; + + let fork = &forks().await[0]; + assert_eq!(fork.def.market, MARKETS[0].market, "scenario is WSOL/USDC"); + + let values = |entries: &[(&str, serde_json::Value)]| { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), value.clone())) + .collect::>() + }; + let account = |address: &str| AccountAddress::Pubkey(address.to_string()); + + let (mut scenario_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + for (address, seeded) in [ + (fork.def.oracle, fork.oracle.clone()), + (fork.def.market, fork.market.clone()), + (fork.def.quote_vault, fork.quote_vault.clone()), + ] { + scenario_svm + .inner + .set_account(Pubkey::from_str_const(address), seeded) + .unwrap_or_else(|error| panic!("seed {address}: {error:?}")); + } + + let mut scenario = Scenario::new( + "SolFi WSOL/USDC risk-off".to_string(), + "Reprice SOL to $50, widen the maker's bid and cap its USDC payout inventory".to_string(), + ); + for (template_id, target, override_values) in [ + ( + "solfi-price", + fork.def.oracle, + values(&[ + ("price_exponent", serde_json::json!(-10)), + ("price_coefficient", serde_json::json!(500_000_000u64)), + ]), + ), + ( + "solfi-freshness", + fork.def.oracle, + values(&[ + ("publication_slot", serde_json::json!(0)), + ("validity_horizon", serde_json::json!(200)), + ]), + ), + ( + "solfi-spread", + fork.def.market, + values(&[ + ("quote_to_base_curve_y", serde_json::json!(1_000u64)), + ("base_to_quote_curve_y", serde_json::json!(10_000u64)), + ("age_multiplier_curve_y", serde_json::json!(1_000u64)), + ("additional_widening_curve_y", serde_json::json!(0u64)), + ("max_widening", serde_json::json!(100_000u64)), + ]), + ), + ( + "solfi-vault-balance", + fork.def.quote_vault, + values(&[("amount", serde_json::json!(PAYOUT_CAPACITY))]), + ), + ] { + scenario.add_override( + OverrideInstance::new(template_id.to_string(), 0, account(target)) + .with_values(override_values), + ); + } + scenario_svm + .register_scenario(scenario, Some(fork.slot)) + .expect("register the Bento scenario"); + scenario_svm + .materialize_overrides_for_slot(&None, fork.slot) + .await + .expect("materialize every Bento override"); + + let materialized = |address: &str| { + scenario_svm + .inner + .get_account(&Pubkey::from_str_const(address)) + .expect("read scenario account") + .unwrap_or_else(|| panic!("missing scenario account {address}")) + .data + }; + let oracle = materialized(fork.def.oracle); + let market = materialized(fork.def.market); + let limited_quote_vault = materialized(fork.def.quote_vault); + assert_eq!(amount(&limited_quote_vault), PAYOUT_CAPACITY); + + let symmetric_control = apply_raw( + "solfi-spread", + &fork.market.data, + &[ + ("quote_to_base_curve_y", serde_json::json!(1_000u64)), + ("base_to_quote_curve_y", serde_json::json!(1_000u64)), + ("age_multiplier_curve_y", serde_json::json!(1_000u64)), + ("additional_widening_curve_y", serde_json::json!(0u64)), + ("max_widening", serde_json::json!(100_000u64)), + ], + fork.slot, + ); + + const SMALL_SELL: u64 = 100_000_000; // 0.1 WSOL + const LARGE_SELL: u64 = 1_000_000_000; // 1 WSOL + const FIVE_USDC: u64 = 5_000_000; + const PAYOUT_CAPACITY: u64 = 25_000_000; // 25 USDC + + let sell_control = run( + fork, + SMALL_SELL, + 0, + symmetric_control.clone(), + oracle.clone(), + ) + .expect("the symmetric $50 control must quote"); + let sell_risk_off = run(fork, SMALL_SELL, 0, market.clone(), oracle.clone()) + .expect("a small SOL sell must remain executable"); + assert!( + (4_900_000..=4_960_000).contains(&sell_risk_off), + "0.1 SOL at a $50 fair value and 1% bid widening returned {sell_risk_off} atomic USDC" + ); + let bid_penalty_ppm = + sell_control.saturating_sub(sell_risk_off) as f64 * 1_000_000.0 / sell_control as f64; + assert!( + (8_500.0..9_500.0).contains(&bid_penalty_ppm), + "moving only the bid from 0.1% to 1% should cost about 0.9%, got {bid_penalty_ppm} ppm" + ); + + let buy_control = + run(fork, FIVE_USDC, 1, symmetric_control, oracle.clone()).expect("buy-side control"); + let buy_risk_off = + run(fork, FIVE_USDC, 1, market.clone(), oracle.clone()).expect("untargeted buy side"); + assert_eq!( + buy_risk_off, buy_control, + "the Bento scenario must not accidentally widen the opposite side" + ); + + replay( + fork, + LARGE_SELL, + 0, + market.clone(), + oracle.clone(), + fork.base_vault.data.clone(), + fork.quote_vault.data.clone(), + ) + .expect("the control inventory must be able to settle the 1 SOL sale"); + + let small_with_limited_inventory = replay( + fork, + SMALL_SELL, + 0, + market.clone(), + oracle.clone(), + fork.base_vault.data.clone(), + limited_quote_vault.clone(), + ) + .expect("25 USDC must still cover the small sell"); + assert!(small_with_limited_inventory > 0); + + let error = replay( + fork, + LARGE_SELL, + 0, + market, + oracle, + fork.base_vault.data.clone(), + limited_quote_vault, + ) + .expect_err("25 USDC cannot cover a 1 SOL sale at the overridden fair value"); + assert!( + error.contains("Custom(18)"), + "expected SolFi's insufficient-liquidity error, got {error}" + ); +} + +#[tokio::test] +async fn solfi_large_trade_deterioration_scenario_interpolates_between_live_knots() { + let forks = forks().await; + let mut checked = 0; + for fork in forks.iter() { + let oracle = apply_raw( + "solfi-freshness", + &fork.oracle.data, + &[ + ("publication_slot", serde_json::json!(0)), + ("validity_horizon", serde_json::json!(200)), + ], + fork.slot, + ); + let tight = directional_spread(fork, 10_000, 10_000, 100_000); + let shaped = size_impact_market(fork, 10_000, 100_000); + let exponent = + (u64::from_le_bytes(oracle[0..8].try_into().unwrap()) ^ EXPONENT_MASK) as i64; + let raw_price = (u64::from_le_bytes(oracle[8..16].try_into().unwrap()) ^ PRICE_MASK) as f64 + * 10f64.powi(exponent as i32); + + for direction in 0..=1 { + let x_offset = if direction == 0 { 864 } else { 728 }; + let first = u64::from_le_bytes( + fork.market.data[x_offset + 8..x_offset + 16] + .try_into() + .unwrap(), + ); + let second = u64::from_le_bytes( + fork.market.data[x_offset + 16..x_offset + 24] + .try_into() + .unwrap(), + ); + assert!(first > 0 && second > first, "live knots must be ordered"); + let midpoint = first + (second - first) / 2; + + let impact_ppm = |quote_notional: u64| { + let amount_in = if direction == 0 { + (quote_notional as f64 / raw_price).round() as u64 + } else { + quote_notional + }; + let control = run(fork, amount_in, direction, tight.clone(), oracle.clone()) + .expect("constant-spread control"); + let deteriorated = run(fork, amount_in, direction, shaped.clone(), oracle.clone()) + .expect("size-impact quote"); + control.saturating_sub(deteriorated) as f64 * 1_000_000.0 / control as f64 + }; + + let at_first = impact_ppm(first); + let at_midpoint = impact_ppm(midpoint); + let at_second = impact_ppm(second); + assert!( + at_first < 2.0, + "{} direction {direction}: first-knot impact {at_first} ppm", + fork.def.market + ); + assert!( + (4_000.0..5_100.0).contains(&at_midpoint), + "{} direction {direction}: midpoint impact {at_midpoint} ppm", + fork.def.market + ); + assert!( + (8_000.0..9_500.0).contains(&at_second), + "{} direction {direction}: second-knot impact {at_second} ppm", + fork.def.market + ); + assert!(at_first < at_midpoint && at_midpoint < at_second); + checked += 1; + } + } + assert_eq!(checked, 4, "both spline directions on both markets"); +} + +#[tokio::test] +async fn solfi_freshness_boundary_is_real_program_behavior_on_both_markets() { + let forks = forks().await; + let mut checked = 0; + for fork in forks.iter() { + let live = apply_raw( + "solfi-freshness", + &fork.oracle.data, + &[ + ("publication_slot", serde_json::json!(0)), + ("validity_horizon", serde_json::json!(0)), + ], + fork.slot, + ); + let control = run(fork, fork.def.base_trade, 0, fork.market.data.clone(), live) + .expect("the validity boundary is inclusive"); + assert!( + control > 0, + "{} must quote at the boundary", + fork.def.market + ); + + let expired = apply_raw( + "solfi-freshness", + &fork.oracle.data, + &[("validity_horizon", serde_json::json!(-1))], + fork.slot, + ); + for direction in 0..=1 { + let amount_in = if direction == 0 { + fork.def.base_trade + } else { + fork.def.quote_trade + }; + let err = run( + fork, + amount_in, + direction, + fork.market.data.clone(), + expired.clone(), + ) + .expect_err("expired oracle must reject"); + assert!( + err.contains("Custom(23)"), + "{} direction {direction}: unexpected expiry error: {err}", + fork.def.market + ); + checked += 1; + } + } + assert_eq!(checked, 4, "both directions on both funded markets"); +} + +#[tokio::test] +async fn solfi_one_sided_inventory_exhaustion_and_inventory_policy_are_real() { + let fork = &forks().await[1]; + for direction in 0..=1 { + let amount_in = if direction == 0 { + fork.def.base_trade + } else { + fork.def.quote_trade + }; + let control = run( + fork, + amount_in, + direction, + fork.market.data.clone(), + fork.oracle.data.clone(), + ) + .expect("funded control"); + assert!(control > 0); + let mut base = fork.base_vault.data.clone(); + let mut quote = fork.quote_vault.data.clone(); + let payout = if direction == 0 { + &mut quote + } else { + &mut base + }; + let starved = amount(payout) / 1000; + *payout = apply_raw( + "solfi-vault-balance", + payout, + &[("amount", serde_json::json!(starved))], + fork.slot, + ); + let err = replay( + fork, + amount_in, + direction, + fork.market.data.clone(), + fork.oracle.data.clone(), + base, + quote, + ) + .expect_err("starved payout vault must refuse"); + assert!( + err.contains("Custom(18)"), + "unexpected liquidity error: {err}" + ); + } + + // The input-side vault is also read for inventory policy. Sweep sizes and magnitudes: the + // decision-tree thresholds are live configuration, so a fixed /10 probe is not a protocol + // guarantee. The mutated vault pays nothing in the tested direction, separating this from a + // token-transfer failure. + let mut checked = 0; + let mut changed = false; + for direction in 0..=1 { + let unit = if direction == 0 { + fork.def.base_trade + } else { + fork.def.quote_trade + }; + for trade_multiple in [1, 10, 100] { + let amount_in = unit.saturating_mul(trade_multiple); + let Ok(baseline) = run( + fork, + amount_in, + direction, + fork.market.data.clone(), + fork.oracle.data.clone(), + ) else { + continue; + }; + for balance_multiple in [2, 10, 100] { + let mut base = fork.base_vault.data.clone(); + let mut quote = fork.quote_vault.data.clone(); + let input_vault = if direction == 0 { + &mut base + } else { + &mut quote + }; + let raised = amount(input_vault).saturating_mul(balance_multiple); + *input_vault = apply_raw( + "solfi-vault-balance", + input_vault, + &[("amount", serde_json::json!(raised))], + fork.slot, + ); + if let Ok(shifted) = replay( + fork, + amount_in, + direction, + fork.market.data.clone(), + fork.oracle.data.clone(), + base, + quote, + ) { + checked += 1; + changed |= shifted != baseline; + } + } + } + } + assert!(checked >= 6, "only {checked} inventory probes settled"); + assert!( + changed, + "no swept input-vault magnitude reached inventory pricing" + ); +} + +#[test] +fn solfi_swap_wire_format_has_no_fee_or_tier_argument() { + let amount_in = 123u64; + let min_out = 456u64; + let direction = 1u8; + let mut data = vec![7u8]; + data.extend_from_slice(&amount_in.to_le_bytes()); + data.extend_from_slice(&min_out.to_le_bytes()); + data.push(direction); + assert_eq!(data.len(), 18); + assert_eq!( + &data, + &[7, 123, 0, 0, 0, 0, 0, 0, 0, 200, 1, 0, 0, 0, 0, 0, 0, 1] + ); +} diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 7ce4c3e08..96bc803e8 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -1002,7 +1002,7 @@ impl Surfpool { } #[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." + 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. For a direct-pubkey template with constants.market, a selected market value replaces the template's default account pubkey; it does not belong in values." )] async fn get_override_templates(&self) -> Result { let registry = self.template_registry.read().map_err(|_| { @@ -1025,7 +1025,7 @@ impl Surfpool { } #[tool( - description = "Searches the options of a template's constants (price feeds, markets, token mints). Use after get_override_templates to resolve a constant_ref value: pass the templateId, optionally the constant name, and a query like \"SOL/USD\". Returns matching options whose `value` field is what create_scenario expects." + description = "Searches the options of a template's constants (price feeds, markets, token mints). Use after get_override_templates: pass the templateId, optionally the constant name, and a query like \"SOL/USD\". Put a property constant in values. For a direct-pubkey template's constants.market catalog, put the selected value in account.pubkey." )] async fn search_constant_options( &self, @@ -1338,6 +1338,18 @@ mod tests { price_feed.get("options").is_none(), "options must not be inlined; they blow past LLM token limits" ); + + let solfi = templates + .as_array() + .unwrap() + .iter() + .find(|t| t["id"] == "solfi-price") + .expect("SolFi template present"); + assert_eq!( + solfi["address"], + serde_json::json!({ "pubkey": "2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou" }) + ); + assert_eq!(solfi["constants"]["market"]["optionsCount"], 2); } #[tokio::test] diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 0e25a7a4a..11fcdc697 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1029,12 +1029,30 @@ pub enum RawEncoding { count: usize, stride: usize, }, + /// An unsigned 64-bit value written to `count` slots, `stride` bytes apart. + U64Strided { + count: usize, + stride: usize, + }, + /// A logical unsigned 64-bit value XORed with `mask` before being written. + U64Xor { + mask: u64, + }, + /// A logical signed 64-bit value XORed with `mask` before being written. + I64Xor { + mask: u64, + }, /// A base58 pubkey, written as 32 bytes. Bytes32, /// The slot the override materializes at, plus `lead` (may be negative). Slot { lead: i64, }, + /// A relative slot XORed with `mask` before being written. + SlotXor { + lead: i64, + mask: u64, + }, } impl RawEncoding { @@ -1044,7 +1062,13 @@ 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::U64Strided { .. } + | RawEncoding::U64Xor { .. } + | RawEncoding::I64 + | RawEncoding::I64Xor { .. } + | RawEncoding::Slot { .. } + | RawEncoding::SlotXor { .. } => 8, RawEncoding::U128 | RawEncoding::I128 => 16, RawEncoding::Bytes32 => 32, } @@ -1056,7 +1080,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::U64Strided { count, stride } => (*count, *stride), other => (1, other.width()), } } @@ -1093,10 +1118,26 @@ impl RawEncoding { RawEncoding::U8 => int!(u8, "u8"), RawEncoding::U16 => int!(u16, "u16"), RawEncoding::U32 => int!(u32, "u32"), - RawEncoding::U64 => int!(u64, "u64"), + RawEncoding::U64 | RawEncoding::U64Strided { .. } => int!(u64, "u64"), + RawEncoding::U64Xor { mask } => { + let d = digits("u64")?; + (d.parse::() + .map_err(|e| format!("invalid u64: '{d}': {e}"))? + ^ mask) + .to_le_bytes() + .to_vec() + } RawEncoding::U128 => int!(u128, "u128"), RawEncoding::I32 | RawEncoding::I32Strided { .. } => int!(i32, "i32"), RawEncoding::I64 => int!(i64, "i64"), + RawEncoding::I64Xor { mask } => { + let d = digits("i64")?; + ((d.parse::() + .map_err(|e| format!("invalid i64: '{d}': {e}"))? as u64) + ^ mask) + .to_le_bytes() + .to_vec() + } RawEncoding::I128 => int!(i128, "i128"), RawEncoding::Bytes32 => { let text = value @@ -1116,19 +1157,36 @@ impl RawEncoding { .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) - }; + let slot = slot_with_lead(target_slot, lead)?; slot.to_le_bytes().to_vec() } + RawEncoding::SlotXor { lead, mask } => { + 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}"))? + } + }; + (slot_with_lead(target_slot, lead)? ^ mask) + .to_le_bytes() + .to_vec() + } }) } } +fn slot_with_lead(target_slot: Slot, lead: i64) -> Result { + if lead >= 0 { + target_slot + .checked_add(lead as u64) + .ok_or_else(|| format!("slot {target_slot} plus lead {lead} exceeds u64::MAX")) + } else { + Ok(target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0)) + } +} + /// 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. @@ -1607,6 +1665,72 @@ mod tests { assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); } + #[test] + fn raw_encoding_handles_xored_u64_and_slot_fields() { + use super::RawEncoding; + + let mask = 0x44dd_2288_77ee_1166; + let logical = 9_997_556_206u64; + let bytes = RawEncoding::U64Xor { mask } + .encode(&json!(logical), 0) + .expect("XOR-obfuscated u64"); + assert_eq!( + u64::from_le_bytes(bytes.try_into().unwrap()), + logical ^ mask + ); + + let exponent_mask = 0x990f_f033_cc55_aaff; + let bytes = RawEncoding::I64Xor { + mask: exponent_mask, + } + .encode(&json!(-10), 0) + .expect("XOR-obfuscated i64"); + assert_eq!( + u64::from_le_bytes(bytes.try_into().unwrap()) ^ exponent_mask, + (-10i64) as u64 + ); + + let slot_mask = 0x9966_33cc_00ff_aa55; + let bytes = RawEncoding::SlotXor { + lead: 200, + mask: slot_mask, + } + .encode(&json!(null), 443_367_679) + .expect("XOR-obfuscated relative slot"); + assert_eq!( + u64::from_le_bytes(bytes.try_into().unwrap()) ^ slot_mask, + 443_367_879 + ); + + let bytes = RawEncoding::SlotXor { + lead: 0, + mask: slot_mask, + } + .encode(&json!(-500), 10) + .expect("negative lead clamps before XOR"); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()) ^ slot_mask, 0); + + let large_slot = i64::MAX as u64 + 42; + let bytes = RawEncoding::SlotXor { + lead: 0, + mask: slot_mask, + } + .encode(&json!(0), large_slot) + .expect("large u64 slot must not truncate"); + assert_eq!( + u64::from_le_bytes(bytes.try_into().unwrap()) ^ slot_mask, + large_slot + ); + + let err = RawEncoding::SlotXor { + lead: 0, + mask: slot_mask, + } + .encode(&json!(1), u64::MAX) + .expect_err("an XOR slot 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}; @@ -1673,6 +1797,47 @@ mod tests { } } + #[test] + fn u64_strided_writes_every_slot_and_nothing_between() { + use super::{Property, RawEncoding, RawLayout}; + let layout = RawLayout { + account_size: 80, + magic: None, + }; + let mut property = Property::field("values".to_string()); + property.offset = Some(8); + property.encoding = Some(RawEncoding::U64Strided { + count: 4, + stride: 16, + }); + + let original = vec![0xa5; 80]; + let out = layout + .materialize( + &original, + &[property], + &HashMap::from([("values".to_string(), json!(50_000))]), + 0, + ) + .expect("strided write"); + + let expected_bytes = 50_000u64.to_le_bytes(); + let expected_indices: std::collections::BTreeSet<_> = (0..4) + .flat_map(|i| { + let at = 8 + i * 16; + at..at + 8 + }) + .collect(); + for i in 0..out.len() { + if expected_indices.contains(&i) { + let at = 8 + ((i - 8) / 16) * 16; + assert_eq!(out[i], expected_bytes[i - at]); + } else { + assert_eq!(out[i], original[i], "unexpected write at byte {i}"); + } + } + } + #[test] fn i32_strided_rejects_a_run_that_leaves_the_account() { use super::{Property, RawEncoding, RawLayout}; From 9899c8c8de4c6a6c7fbf876edf2d2787e4d79080 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 17 Sep 2026 14:07:33 +0300 Subject: [PATCH 2/2] docs(solfi): replace persistence guidance with explicit freshness scheduling --- .../scenarios/protocols/solfi/v2/README.md | 37 ++++++++++--------- .../protocols/solfi/v2/market-overrides.yaml | 11 +++--- .../protocols/solfi/v2/oracle-overrides.yaml | 11 +++--- .../protocols/solfi/v2/vault-overrides.yaml | 5 ++- 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/crates/core/src/scenarios/protocols/solfi/v2/README.md b/crates/core/src/scenarios/protocols/solfi/v2/README.md index 51dadca9b..86606d54d 100644 --- a/crates/core/src/scenarios/protocols/solfi/v2/README.md +++ b/crates/core/src/scenarios/protocols/solfi/v2/README.md @@ -56,13 +56,13 @@ Do not reuse an oracle or vault merely because the token pair looks similar. Use **1. Keep the oracle current while testing price or widening.** A price can be encoded correctly and still never reach the quote if the oracle has expired. Apply `solfi-freshness` with -`publication_slot: 0` and `validity_horizon: 200` when setup spans multiple slots. If bounded -persistence is installed, prefer `persist: { slots: N }` for the scenario window. On a raw-layout-only -installation, use `persist: true` and stop it when the scenario no longer needs a current quote. +`publication_slot: 0` and `validity_horizon: 200` when setup spans multiple slots. That keeps the +quote valid for 200 slots. For a longer scenario, schedule the same freshness override again in each +later slot where a quote is needed. -**2. Persist configuration, not transaction-owned inventory.** Price, freshness and spline settings -are inputs and may be reapplied for the scenario window. A vault balance is state that swaps modify. -Persisting it can undo a swap after every slot and manufacture or erase inventory. +**2. Do not repeatedly reset transaction-owned inventory.** Price, freshness and spline settings are +configuration inputs. A vault balance is state that swaps modify. Reapplying a vault override after +every swap can undo the swap and manufacture or erase inventory. ## Scenario ideas @@ -77,8 +77,8 @@ SOL, and limits its USDC payout inventory to 25 USDC. usable. 3. Use `solfi-spread` with buy-base widening `1000`, sell-base widening `10000`, age multiplier `1000`, additional widening `0`, and maximum widening `100000`. -4. Use `solfi-vault-balance` on the WSOL/USDC **USDC vault** with amount `25000000`. Do not persist - this transaction-owned balance. +4. Use `solfi-vault-balance` on the WSOL/USDC **USDC vault** with amount `25000000`. Apply this + transaction-owned balance once before the swaps being tested. The deployed-program integration test confirms the complete four-template scenario: a 0.1 SOL sale still fills near 4.95 USDC, the opposite direction is not widened, a 1 SOL control sale fills with @@ -93,7 +93,7 @@ price moves suddenly. 2. Set both price fields to the new price. For example, exponent `-10` and coefficient `500000000` means $50 per SOL. 3. Add `solfi-freshness` for **WSOL / USDC** with publication slot `0` and validity horizon `200`. -4. Persist both overrides if the test runs for more than one slot. +4. If the swap executes more than 200 slots later, schedule another freshness override in that slot. 5. Compare a swap before and after the price change. Also keep a run with the original price as a control. @@ -137,8 +137,8 @@ token it must pay. 1. In `solfi-vault-balance`, choose the WSOL or USDT vault to block users buying the base asset, or choose the USDC vault to block users selling the base asset. 2. Lower the balance enough that the requested swap cannot be paid. -3. Do not persist the vault balance unless resetting inventory after every transaction is explicitly - part of the test. +3. Do not schedule repeated vault resets unless restoring inventory after every transaction is + explicitly part of the test. 4. Confirm the affected swap fails with SolFi error 18 and that the opposite direction or an alternative venue still works. @@ -156,8 +156,9 @@ Set both fields. Doubling the coefficient doubles base-to-quote output and halve output, subject to spread and rounding. The price-looking word in the market account is not the authoritative input, but changing the external oracle is what reprices a fill. -For a multi-slot scenario, reapply both `solfi-price` and `solfi-freshness` for the required window. -Use bounded persistence when that generic feature is installed; otherwise use `persist: true`. +For a multi-slot scenario, `solfi-freshness` keeps the quote valid for its configured horizon. If a +swap executes after that horizon, schedule another freshness override in the execution slot. The +price remains set unless another override or transaction writes the oracle account. Repricing only SolFi while leaving another venue unchanged creates a real cross-venue dislocation suitable for router, arbitrage and liquidation-path testing. Always include an undislocated control leg. @@ -167,11 +168,11 @@ suitable for router, arbitrage and liquidation-path testing. Always include an u template: solfi-freshness publication_slot: 0 validity_horizon: 200 -persist: { slots: 200 } # when bounded persistence is available; otherwise use true ``` Both inputs are relative offsets even though the account stores XOR-obfuscated absolute slots. -Re-stamp both fields to model a continuously publishing maker. +This keeps the quote valid for 200 slots from materialization. For a longer scenario, schedule this +template again in each later slot where a quote is needed. An expired SolFi oracle rejects the transaction with error 23. @@ -263,8 +264,8 @@ The base vault pays quote-to-base swaps, the quote vault pays base-to-quote swap payout vault far enough makes that direction reject with SolFi error 18. Vaults also enter nonlinear inventory policy, so changing the input-side vault can move a quote even though it is not paying out. -This is not an AMM reserve-price formula. Use `solfi-price` to change the mid. Do not persist a vault -override unless restoring the same inventory after every transaction is deliberately the scenario. +This is not an AMM reserve-price formula. Use `solfi-price` to change the mid. Do not repeatedly +reset a vault unless restoring the same inventory after every transaction is deliberately the scenario. # Troubleshooting @@ -276,4 +277,4 @@ override unless restoring the same inventory after every transaction is delibera | Constant spread is smaller than requested | Account for `oracle_scale`, neutralize the age/additional curves, and raise `max_widening` | | Size impact appears at the wrong base amount | Breakpoints are quote notional. Base input is converted at the oracle price first | | One direction widened instead of the other | Quote-to-base is buying base. Base-to-quote is selling base | -| A vault balance returns after a swap | Remove persistence. Repeated application is undoing transaction-owned state | +| A vault balance returns after a swap | Remove the later vault reset; it is undoing transaction-owned state | diff --git a/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml b/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml index 6bbde1aeb..5d0f392f7 100644 --- a/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml +++ b/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml @@ -83,8 +83,8 @@ templates: llm_context: | PRECONDITION - THE ORACLE MUST BE CURRENT. SolFi rejects an expired oracle before pricing, so an otherwise correct spread can appear to do nothing. For a multi-slot scenario also apply - solfi-freshness with publication_slot 0 and validity_horizon 200. If bounded persistence is - available, use persist: { slots: N } for the scenario window; otherwise use persist: true. + solfi-freshness with publication_slot 0 and validity_horizon 200. If the scenario runs beyond + that horizon, schedule another freshness override in each later slot where a quote is needed. This template deliberately writes all coupled inputs needed for deterministic widening. Tables 1 and 2 are direction-specific and keyed by quote-token notional. Quote-to-base uses raw quote @@ -117,7 +117,7 @@ templates: DIRECTIONAL RISK-OFF: raise quote_to_base_curve_y when the maker does not want to sell more base; raise base_to_quote_curve_y when it does not want to buy more base. Set both for a - symmetric spread. These are configuration inputs and may be persisted. + symmetric spread. - id: solfi-size-impact name: Override Size Impact @@ -160,8 +160,9 @@ templates: description: Final clamp in tenths of a ppm; 100000 is 1%. llm_context: | PRECONDITION - THE ORACLE MUST BE CURRENT. Apply solfi-freshness when setup spans multiple - slots, otherwise expiry can hide a correctly materialized size curve. Prefer - persist: { slots: N } when bounded persistence is available; otherwise use persist: true. + slots, otherwise expiry can hide a correctly materialized size curve. If the scenario runs + beyond the configured validity horizon, schedule another freshness override in each later slot + where a quote is needed. This is the advanced form of SolFi spread control. It preserves the eight x breakpoints that are already configured on the live market and replaces their y values. The deployed program diff --git a/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml b/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml index b914bee47..0652d6da8 100644 --- a/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml +++ b/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml @@ -79,8 +79,8 @@ templates: Price and liveness are independent: changing the coefficient alone reprices immediately, while an expired validity horizon rejects the swap before the new price can be used. For scenarios - that run for many slots, also apply solfi-freshness. Prefer persist: { slots: N } when bounded - persistence is available; otherwise use persist: true. + that run for many slots, also apply solfi-freshness. If the scenario runs beyond that validity + horizon, schedule another freshness override in each later slot where a quote is needed. DIRECTION: raising the price increases base-to-quote output and reduces the amount of base paid by quote-to-base. The deployed-program test confirms the reciprocal response in both directions. @@ -119,8 +119,8 @@ templates: Both fields are relative slot offsets even though the stored words are absolute slots hidden by XOR masks. Set publication_slot to 0 and validity_horizon to 200 to emulate a current maker. - For a finite scenario, prefer persist: { slots: N } when bounded persistence is available; - otherwise use persist: true and stop it when the scenario no longer needs a current quote. + This keeps the quote valid for 200 slots from the slot where the override materializes. For a + longer scenario, schedule the same override again in each later slot where a quote is needed. The boundary is inclusive: validity_horizon 0 is valid in the materialization slot, while -1 is expired and both swap directions revert with SolFi error 23. @@ -128,5 +128,4 @@ templates: HOW TO USE: 1. Set publication_slot to 0 2. Set validity_horizon to 200 - 3. Reapply both offsets each slot: use persist: { slots: N } when bounded persistence is - available, or persist: true as the raw-layout-compatible fallback + 3. If a quote is needed after those 200 slots, schedule this template again in that later slot diff --git a/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml b/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml index f4d5a6242..4f73802c5 100644 --- a/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml +++ b/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml @@ -77,8 +77,9 @@ templates: Vault balances also enter SolFi's nonlinear inventory policy, so changing the input-side vault can move a quote even though that vault is not paying out. This does not set SolFi's mid-price. - Do not persist this override across swaps. A persistent vault override can manufacture or erase - inventory after each transaction by undoing transaction-owned balance changes. + Apply this override once before the swap being tested. Do not schedule repeated vault resets + across swaps: that can manufacture or erase inventory by undoing transaction-owned balance + changes. LOWER FOR STRESS TESTS. A moderate reduction can move nonlinear inventory policy; a severe reduction of the payout vault produces error 18. Raising a wrapped-native vault amount without