diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 2a7ff8005..d30ef7771 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..86606d54d --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/README.md @@ -0,0 +1,280 @@ +# 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. 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. 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 + +### 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`. 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 +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. 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. + +### 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 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. + +# 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, `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. + +## Keep the quote current + +```text +template: solfi-freshness +publication_slot: 0 +validity_horizon: 200 +``` + +Both inputs are relative offsets even though the account stores XOR-obfuscated absolute slots. +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. + +## 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 repeatedly +reset a vault 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 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 new file mode 100644 index 000000000..5d0f392f7 --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/market-overrides.yaml @@ -0,0 +1,184 @@ +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 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 + 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. + + - 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. 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 + 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..0652d6da8 --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/oracle-overrides.yaml @@ -0,0 +1,131 @@ +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. 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. + + 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. + 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. + + HOW TO USE: + 1. Set publication_slot to 0 + 2. Set validity_horizon to 200 + 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 new file mode 100644 index 000000000..4f73802c5 --- /dev/null +++ b/crates/core/src/scenarios/protocols/solfi/v2/vault-overrides.yaml @@ -0,0 +1,86 @@ +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. + + 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 + 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 97480ea72..308d9f500 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"); @@ -518,11 +530,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")); @@ -593,6 +605,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] @@ -883,8 +900,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()); @@ -1603,4 +1620,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 d98665cbd..7cb27029b 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -1047,7 +1047,7 @@ impl Surfpool { } #[tool( - description = "Fetches one template's full detail (properties, address, constants summarized as {label, description, optionsCount}, and llmContext). Call after get_override_templates with the id you picked, before create_scenario. Resolve an actual constant option value with search_constant_options." + description = "Fetches one template's full detail (properties, address, constants summarized as {label, description, optionsCount}, and llmContext). Call after get_override_templates with the id you picked, before create_scenario. Resolve an actual constant option value with search_constant_options. For a direct-pubkey template with constants.market, the selected market replaces account.pubkey; it does not belong in values." )] async fn get_override_template( &self, @@ -1076,7 +1076,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 constant_ref property's selected value in values. For a direct-pubkey template's constants.market catalog, put the selected value in account.pubkey." )] async fn search_constant_options( &self, @@ -1427,6 +1427,18 @@ mod tests { price_feed.get("options").is_none(), "options must not be inlined; they blow past LLM token limits" ); + + let result = surfpool + .get_override_template(template_id("solfi-price")) + .await + .expect("SolFi template detail"); + assert_ne!(result.is_error, Some(true)); + let solfi = json_of(&result); + 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 2da0f3a21..b2e3ca134 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1016,12 +1016,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 { @@ -1031,7 +1049,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, } @@ -1043,7 +1067,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()), } } @@ -1080,10 +1105,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 @@ -1103,19 +1144,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. @@ -1662,6 +1720,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}; @@ -1728,6 +1852,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};