diff --git a/.github/workflows/openai-review.yml b/.github/workflows/openai-review.yml new file mode 100644 index 00000000..07fd8a68 --- /dev/null +++ b/.github/workflows/openai-review.yml @@ -0,0 +1,73 @@ +name: Perform a code review when a pull request is created. +on: + pull_request: + +jobs: + codex: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + final_message: ${{ steps.run_codex.outputs.final-message }} + steps: + - uses: actions/checkout@v5 + with: + # Explicitly check out the PR's merge commit. + ref: refs/pull/${{ github.event.pull_request.number }}/merge + persist-credentials: false + + - name: Pre-fetch base and head refs for the PR + env: + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Pass GitHub expressions through env and quote shell expansions. + git fetch --no-tags origin \ + "$PR_BASE_REF" \ + "+refs/pull/$PR_NUMBER/head" + + # If you want Codex to build and run code, install any dependencies that + # need to be downloaded before the "Run Codex" step. The recommended + # :workspace permission profile does not grant network access. + + - name: Run Codex + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + permission-profile: ":workspace" + prompt: | + This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. + + Review ONLY the changes introduced by the PR, so consider: + git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + + Suggest any improvements, potential bugs, or issues. + Be concise and specific in your feedback. + + Pull request title and body: + ---- + ${{ github.event.pull_request.title }} + ${{ github.event.pull_request.body }} + + post_feedback: + runs-on: ubuntu-latest + needs: codex + if: needs.codex.outputs.final_message != '' + permissions: + issues: write + pull-requests: write + steps: + - name: Report Codex feedback + uses: actions/github-script@v7 + env: + CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }} + with: + github-token: ${{ github.token }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: process.env.CODEX_FINAL_MESSAGE, + }); \ No newline at end of file diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 70e080af..695ce09a 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -18,6 +18,7 @@ Protocols that are natively supported by Surfpool will have their IDLs included - **Switchboard On-Demand** - On-demand oracle with QuoteAccount override template - **Kamino** – Lending (v1.23.0), Scope oracle, Farms, Swap/LIMO, Earn vaults and Liquidity, across six programs. See [protocols/kamino/README.md](./protocols/kamino/README.md) - **Drift v2** - Perp and spot markets, user state, and global state +- **BisonFi v3** – Proprietary market maker (no published IDL, not Anchor), with price, depth, spread and freshness templates. See [protocols/bisonfi/README.md](./protocols/bisonfi/README.md) - **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 @@ -44,18 +45,58 @@ itself after every swap. Only one entry is queued per override, so it is never a one slot, and `fetchBeforeUse` applies to the first slot only - once the account is forked, later slots re-pin the fields without re-fetching it. -### Kamino integration tests +### On-chain integration tests -Byte-level Kamino coverage lives in `crates/core/src/tests/kamino/`. Those tests fetch the real -accounts from mainnet, so they need a network connection and are compiled only behind a feature: +Byte-level coverage that forks real mainnet state lives in two modules, +`crates/core/src/tests/kamino/` and `crates/core/src/tests/bisonfi/`. Both fetch real accounts, so +they need a network connection and are compiled only behind a feature: ``` +# both suites +cargo test -p surfpool-core --features integration-tests + +# one at a time cargo test -p surfpool-core --features integration-tests kamino +cargo test -p surfpool-core --features integration-tests bisonfi ``` +Note the per-suite filters are substring matches on the full test path, so `kamino` covers only the +Kamino module. It used to sweep up the BisonFi tests as well, back when they lived inside +`tests/kamino/` and were named `tests::kamino::bisonfi_*` - if you are following an older note that +says the `kamino` filter is enough, it no longer is. + Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint instead of the public one. The default test run needs no network. +### Programs with no IDL + +Some programs publish no IDL and are not Anchor at all, so there is no discriminator to resolve an +account type. Those ship a byte layout in their `overrides.yaml` instead: + +```yaml +raw_layout: + account_size: 2048 + magic: { offset: 0, bytes: [80, 79, 79, 76, 83, 84, 65, 84] } # optional + +templates: + - id: bisonfi-fair-value + properties: + - path: fair_value + offset: 832 + encoding: u128 # u8/u16/u32/u64/u128/i64/i128/bytes32/slot +``` + +When a template carries a `raw_layout` the engine writes bytes at each property's offset instead of +decoding through the IDL. `account_size` and `magic` replace the discriminator as the check that +this is the right account - without them a raw write would silently corrupt an unrelated one. + +Make that guard as narrow as the layout actually is. Size and a magic prefix are often not enough: +BisonFi has eighteen accounts that are all 2048 bytes with the same `POOLSTAT` prefix, but one of +them is an older layout version, so the magic is extended to cover the version word that follows it. +Any field the program itself validates before trusting the account is a candidate for the guard. +Values are written little-endian and integer-exact; anything above `u64::MAX` must be passed as a +decimal string, since a JSON number that large has already lost digits. + ### Override Templates Directly using the `surfnet_registerScenario` endpoint requires building out a map of account keys that are specific to the schema of the account that is being written to. This is a cumbersome process in most cases. diff --git a/crates/core/src/scenarios/protocols/bisonfi/README.md b/crates/core/src/scenarios/protocols/bisonfi/README.md new file mode 100644 index 00000000..14ad284f --- /dev/null +++ b/crates/core/src/scenarios/protocols/bisonfi/README.md @@ -0,0 +1,144 @@ +# BisonFi + +A proprietary market maker (PMM), not an AMM. Four templates: price, depth, spread and freshness. + +Because it is a market maker rather than a curve, it can be put into states no constant-product pool +can reach - quoting wide with deep inventory, or refusing to quote at all. Those are the scenarios +worth reaching for this protocol to test. + +# Template index + +| Template | Overrides | +|---|---| +| `bisonfi-fair-value` | the mid price BisonFi quotes around | +| `bisonfi-depth` | how far a trade moves BisonFi's price | +| `bisonfi-spread` | the spread BisonFi quotes around its mid | +| `bisonfi-freshness` | whether BisonFi's quote is live | + + +## Number formats + +| You'll see | It means | Example | +|---|---|---| +| `fair_value` | price x 2^88, as a decimal **string** | $50 -> `"15474250491067253436239052800"` | +| `tick_offset` | 1/2,560,000 of the mid | `25600` = 1%, `2560` = 10 bps, `256` = 100 ppm | +| reserves | the mint's smallest unit | 1 USDC -> `1000000` | +| `last_update_slot` | an absolute slot number | | + +`fair_value` exceeds what a JSON number holds exactly, so it must be quoted. To convert a spread: +`ticks = percent * 25600`. + +## Picking a market + +The templates default to the live WSOL/USDC market `8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo`. +Other markets are found by reading `base_mint` and `quote_mint` on the accounts the program owns. + +Only version-3 pool accounts are supported and the guard rejects the one remaining version-2 account +rather than write a price into the wrong field. + +# Recipes + +## Set a price + +``` +template: bisonfi-fair-value +fair_value: "15474250491067253436239052800" # $50 x 2^88, as a STRING +``` + +Set `fetchBeforeUse: true` so the live pool is forked first. + +## Make large trades slip + +``` +template: bisonfi-depth +quote_reserve: # makes SELLING the base asset expensive +base_reserve: # makes BUYING it expensive +``` + +The side the pool pays *out* of is the side that constrains the trade. Set both if the scenario does +not fix a direction. + +**Reach for an order of magnitude.** The response is not linear - a trade worth a couple of percent of +a reserve barely notices that reserve being quartered. + +**Lower, never raise.** These fields mirror the balances of the vaults, which this template does not +touch. Lowering is safe. Raising one above the vault's real balance makes the program compute a payout +the vault cannot cover, and the swap fails when it settles. + +## Make a market unable to fill + +The same template, taken further - around `quote_reserve / 10` the swap stops slipping and starts +failing outright with an insufficient-liquidity error. Useful for testing how a router handles a venue +that cannot fill at all. + +## Keep the venue quoting + +**A forked pool goes stale by itself after two slots** - nothing in a fork republishes the mid, and +once stale the price, depth and spread templates are silently ignored. Refresh the timestamp to keep +the venue alive for as long as your scenario needs. + +``` +template: bisonfi-freshness +last_update_slot: +persist: true +``` + +Refreshing resumes the price the venue already held - no new price is needed. Without `persist` the +next slot's state overwrites yours. + +If your scenario executes within a slot of forking you do not need this. If it spends longer than +that on setup, you do. + +## Quote a wide spread + +``` +template: bisonfi-spread +working_levels.0.tick_offset: -25600 # 1% below mid +configured_levels.0.tick_offset: -25600 +continuation_levels.0.tick_offset: -25600 +continuation_source_levels.0.tick_offset: -25600 +``` + +**Set all four properties of a side, or all eight.** The `.0.` paths are the bid side, the `.4.` and +`.5.` paths the ask side. Setting only some of them produces a spread that varies with timing. + +**Signs matter.** Bid offsets are negative and price SELLS of the base token. Ask offsets are positive +and price BUYS. + +Do not use `0` to mean "no offset" - use a small magnitude instead. + +## Reprice or widen mid-flight + +Schedule two steps on the same field a couple of slots apart: the caller prices on one number and +executes against another. Works with `bisonfi-fair-value` (the mid moves) or `bisonfi-spread` (the +maker widens). + +Both **revert**, caught by the caller's own minimum-output bound - the opposite symptom to a dark +maker, which succeeds with zero. Testing the pair is more informative than either alone: one failure +is detectable by a consumer and one is not. + +## Arbitrage against an AMM + +Move `fair_value` away from an AMM's price on the same pair and the two venues disagree by a real, +executable margin - both legs fit in one transaction. Two things to get right: + +- **Use an exact-output swap on the AMM leg.** Instruction amounts are fixed when the transaction is + built, so a leg that buys "whatever N USDC gets" cannot be followed by one that sells exactly that. + Ask the AMM for a known quantity and pay whatever it costs. +- **Expect the undislocated round trip to lose money** - the taker pays a fee on both venues. The + dislocation has to clear that before any profit appears, and a control run showing a profit at the + true mid means you are measuring something other than a round trip. + +# Troubleshooting + +| Symptom | Fix | +|---|---| +| A price, depth or spread override had no effect and nothing errored | The quote is stale, and the freshness gate runs first. Refresh `last_update_slot` - see "Keep the venue quoting" | +| The pool quotes nothing at any size | Probably one of the dormant markets. Check how far `last_update_slot` is behind the chain | +| A spread override does nothing | You set some of a side's four properties but not all, or the trade is too small - very small trades do not consult the ladder. Try a percent or so of `base_reserve`, and try a few sizes | +| A stale market returns 0 instead of reverting | Not a bug: a stale venue returns zero and the transaction SUCCEEDS, and the swap's minimum-output bound is not enforced on that path | +| The override reverts after the next slot | Add `persist: true` | +| The guard rejects the account | Only version-3 pools are supported | +| `Custom(60)` | A Token-2022 mint whose token accounts need matching extension data. Two live markets quote such an asset | +| A swap in a simulated slot returns 0 for no reason | The `LastRestartSlot` sysvar must be at least `246464040`, and the default 200k compute budget cannot finish a large trade - ask for ~1.4M | +| A freshness override does not seem to age the pool | If your harness derives its clock from the pool's own `last_update_slot`, aging the account moves the clock with it. Apply the override after the clock is taken | \ No newline at end of file diff --git a/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml new file mode 100644 index 00000000..0ead16eb --- /dev/null +++ b/crates/core/src/scenarios/protocols/bisonfi/overrides.yaml @@ -0,0 +1,276 @@ +protocol: BisonFi +# The pool-account version this layout describes, and the only one supported. BisonFi ships no +# program semver; the guard below rejects the one remaining version-2 account outright. +version: v3 +account_type: PoolStat + +# BisonFi publishes no IDL and none is reconstructed here. Writes go through the byte layout below. +raw_layout: + account_size: 2048 + magic: + offset: 0 + # "POOLSTAT" followed by the u64 version, which must be 3. + # + # The version is part of the guard on purpose: size and magic alone admit an older account whose + # fields sit elsewhere, and a scenario naming it would write a price into an unrelated field. + bytes: [80, 79, 79, 76, 83, 84, 65, 84, 3, 0, 0, 0, 0, 0, 0, 0] + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: bisonfi-fair-value + name: Override BisonFi Fair Value + description: Override the mid price BisonFi quotes around + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: fair_value + offset: 832 + encoding: u128 + label: Fair value + description: >- + Mid price as a fixed-point integer scaled by 2^88. Pass it as a decimal string. + Example: $75.45 is "23350643991020486314894032896" + llm_context: | + PRECONDITION - THE QUOTE MUST BE FRESH. If the venue's quote is two or more slots stale this + override is silently ignored: the swap returns zero and the transaction still SUCCEEDS, with + your value sitting correctly in the account. A forked pool goes stale on its own after two + slots, because nothing in a fork republishes the mid. If your scenario spends more than a slot + before executing, refresh last_update_slot with bisonfi-freshness first. + + This is the only price lever. The reserves control depth, not price: BisonFi quotes around a mid + the operator publishes, so changing vault balances will not move the quote. + + HOW TO USE: + 1. Multiply the price by 2^88 (309485009821345068724781056) + 2. Pass the result as a decimal STRING, since it exceeds what a JSON number holds exactly + 3. Set fetchBeforeUse: true so the live pool is forked first + + The default address is the live WSOL/USDC market. Other markets are found by reading base_mint + and quote_mint on the accounts the program owns. + + MID-FLIGHT VARIANT: schedule two steps on this field a couple of slots apart, and a caller + prices on one mid and executes against another. A reprice is caught by the caller's own + minimum-output bound, so the transaction reverts rather than filling at the worse price. + + CROSS-VENUE ARBITRAGE: moving this away from an AMM's price on the same pair creates an + executable arbitrage, with both legs in one transaction. Two practical notes: use an + exact-OUTPUT swap on the AMM leg, because instruction amounts are fixed when the transaction is + built and the second leg needs a known size; and expect the undislocated round trip to LOSE + money, since the taker pays a fee on both venues. A control run showing a profit at the true mid + is measuring something other than a round trip. + + EXAMPLE - "SOL is worth $50": + fair_value: "15474250491067253436239052800" + - id: bisonfi-depth + name: Override BisonFi Depth + description: Make BisonFi shallower so large trades move its price + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: base_reserve + offset: 48 + encoding: u64 + label: Base reserve + description: >- + Base tokens the pool can pay out, in the mint's smallest unit. Constrains a BUY of the base + token. Example: 204479526927 + - path: quote_reserve + offset: 56 + encoding: u64 + label: Quote reserve + description: >- + Quote tokens the pool can pay out, in the mint's smallest unit. Constrains a SELL of the base + token. Example: 22008930770 + llm_context: | + PRECONDITION - THE QUOTE MUST BE FRESH. If the venue's quote is two or more slots stale this + override is silently ignored: the swap returns zero and the transaction still SUCCEEDS, with + your value sitting correctly in the account. A forked pool goes stale on its own after two + slots, because nothing in a fork republishes the mid. If your scenario spends more than a slot + before executing, refresh last_update_slot with bisonfi-freshness first. + + The depth lever: how far a trade moves the price. For how wide the venue quotes around its mid, + use bisonfi-spread instead. + + WHICH FIELD TO SET: the pool pays out of one side, and that side's reserve constrains the trade. + To make SELLING the base token expensive, lower quote_reserve. To make BUYING it expensive, + lower base_reserve. Set both if the scenario does not fix a direction. + + REACH FOR AN ORDER OF MAGNITUDE. The response is not linear: a trade worth a couple of percent + of a reserve barely notices that reserve being quartered. A large reduction produces slippage; a + very large one produces an outright "insufficient liquidity" refusal, which is useful in itself + for testing how a router handles a venue that cannot fill. + + LOWER, DO NOT RAISE. These fields mirror the balances of the token accounts named by base_vault + and quote_vault, which this template does not touch. Lowering is safe: the pool quotes and pays + out less than it really holds. Raising one above the vault's real balance makes the program + compute a payout the vault cannot cover, and the swap fails when it settles. + + The two directions are largely but not perfectly independent. Treat them as independent for + slippage-scale testing; do not assert that one is untouched to the byte. + + EXAMPLE - "BisonFi is thin, selling SOL into it slips badly": + quote_reserve: 1100446538524 + - id: bisonfi-spread + name: Override BisonFi Spread + description: Widen or tighten the spread BisonFi quotes around its mid + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: working_levels.0.tick_offset + offset: 300 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Bid spread, working book + description: >- + Sell-side offset, outer levels excluded. Units of 1/2,560,000 of the mid, NEGATIVE. -25600 is 1% below mid. + - path: working_levels.4.tick_offset + offset: 364 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Ask spread, working book + description: >- + Buy-side offset, outer levels excluded. POSITIVE. 25600 is 1% above mid. + - path: configured_levels.0.tick_offset + offset: 540 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Bid spread, source book + description: >- + Companion bid run. Set it to the same value as the working bid spread. + - path: configured_levels.4.tick_offset + offset: 604 + encoding: + i32_strided: + count: 4 + stride: 16 + label: Ask spread, source book + description: >- + Companion ask run. Set it to the same value as the working ask spread. + - path: continuation_levels.0.tick_offset + offset: 1048 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Bid spread, working outer book + description: >- + Sell-side offset for the outer levels. NEGATIVE. + - path: continuation_levels.5.tick_offset + offset: 1128 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Ask spread, working outer book + description: >- + Buy-side offset for the outer levels. POSITIVE. + - path: continuation_source_levels.0.tick_offset + offset: 1208 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Bid spread, source outer book + description: >- + Companion outer bid run. Same value as the working outer bid spread. + - path: continuation_source_levels.5.tick_offset + offset: 1288 + encoding: + i32_strided: + count: 5 + stride: 16 + label: Ask spread, source outer book + description: >- + Companion outer ask run. Same value as the working outer ask spread. + llm_context: | + PRECONDITION - THE QUOTE MUST BE FRESH. If the venue's quote is two or more slots stale this + override is silently ignored: the swap returns zero and the transaction still SUCCEEDS, with + your value sitting correctly in the account. A forked pool goes stale on its own after two + slots, because nothing in a fork republishes the mid. If your scenario spends more than a slot + before executing, refresh last_update_slot with bisonfi-freshness first. + + The spread lever: how wide the venue quotes around its mid, independently of how much inventory + it holds. Use bisonfi-depth to make a venue THIN and this one to make it EXPENSIVE. + + THE UNIT IS 1/2,560,000 OF THE MID. So 25600 is 1%, 2560 is 10 bps and 256 is 100 ppm. To + convert a target spread: ticks = percent * 25600. + + SET ALL FOUR BID PROPERTIES TO THE SAME VALUE, or all four ask properties, or all eight. Each + property writes one run of the book and they have to agree; setting only some of them produces a + spread that varies with timing. + + SIGNS MATTER. Bid offsets are negative and price SELLS of the base token. Ask offsets are + positive and price BUYS. + + DO NOT SET A TICK TO ZERO to mean "no offset" - use a small magnitude instead. + + TRADE SIZE MATTERS. Very small trades do not consult the ladder at all, and very large ones stop + paying the full spread. Size a test trade at a percent or so of the pool's base_reserve, and try + a few sizes before concluding the lever did nothing. + + EXAMPLE - "BisonFi is quoting 1% wide on the sell side": + working_levels.0.tick_offset: -25600 + configured_levels.0.tick_offset: -25600 + continuation_levels.0.tick_offset: -25600 + continuation_source_levels.0.tick_offset: -25600 + - id: bisonfi-freshness + name: Override BisonFi Quote Freshness + description: Keep BisonFi's published quote live + idl_account_name: PoolStat + address: + type: pubkey + value: 8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo + properties: + - path: last_update_slot + offset: 72 + encoding: + slot: + lead: 0 + label: Slots behind the chain + description: >- + How far behind the executing slot the quote was published, as a signed offset. 0 is live, + -1 still fills, -2 or lower is silent. + llm_context: | + This field is the venue's liveness signal. + + A FORKED POOL GOES STALE BY ITSELF. Surfpool takes its starting slot from mainnet and never + re-fetches an account it has already pulled, so nothing republishes the mid. Two slots after the + fork the venue stops quoting and stays that way, and from then on the price, depth and spread + overrides are silently ignored - the swap returns zero and the transaction still SUCCEEDS. That + makes this template the precondition for the other three: if your scenario spends more than a + slot before executing, refresh this field first. + + THE VALUE IS AN OFFSET, NOT A SLOT NUMBER. It is resolved against the slot the override + materializes at, so 0 means "published this slot". An absolute slot number would be wrong here: + persist replays the same value every slot, so a fixed number ages by one slot per slot and the + quote goes stale anyway. + + HOW TO USE THIS TEMPLATE: + 1. Set last_update_slot to 0. The venue resumes quoting the price it already held - a fresh + timestamp is enough, no new price is needed + 2. Set persist: true, so every slot re-stamps itself and the quote stays live indefinitely + + A scenario that executes within a slot of forking does not need this. One that spends longer on + setup does. + + EXAMPLE - "keep the maker quoting for the whole run": + last_update_slot: 0, persist: true + + EXAMPLE - "the maker went dark five slots ago": + last_update_slot: -5 diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 217ac803..4f95a059 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -23,6 +23,8 @@ 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 BISONFI_OVERRIDES_CONTENT: &str = include_str!("./protocols/bisonfi/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 +78,7 @@ impl TemplateRegistry { default.load_raydium_overrides(); default.load_meteora_overrides(); default.load_kamino_overrides(); + default.load_bisonfi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -116,6 +119,10 @@ impl TemplateRegistry { ); } + pub fn load_bisonfi_overrides(&mut self) { + self.load_protocol_overrides_without_idl(BISONFI_OVERRIDES_CONTENT, "bisonfi"); + } + pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -189,7 +196,25 @@ impl TemplateRegistry { Ok(idl) => idl, Err(e) => panic!("unable to load {} idl: {}", protocol_name, e), }; + self.load_collection(Some(idl), overrides_content, protocol_name); + } + /// For programs that publish no IDL. Their templates must carry a `raw_layout` and spell out + /// every property description, since there is no schema to fall back on. + fn load_protocol_overrides_without_idl( + &mut self, + overrides_content: &str, + protocol_name: &str, + ) { + self.load_collection(None, overrides_content, protocol_name); + } + + fn load_collection( + &mut self, + idl: Option, + overrides_content: &str, + protocol_name: &str, + ) { let collection = match serde_yaml::from_str::(overrides_content) { Ok(c) => c, @@ -480,11 +505,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) + BisonFi (4) = 66 assert_eq!( registry.count(), - 62, - "Registry should load 62 templates total" + 66, + "Registry should load 66 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); @@ -717,7 +742,7 @@ mod tests { let registry = TemplateRegistry::new(); let jupiter_template = registry.get("jupiter-token-ledger-override").unwrap(); let has_token_ledger = jupiter_template - .idl + .idl() .accounts .iter() .any(|acc| acc.name == "TokenLedger"); @@ -1157,18 +1182,24 @@ mod tests { let registry = TemplateRegistry::new(); let mut errors = Vec::new(); + let mut checked = 0usize; for template in registry.all() { + // Templates for programs that publish no IDL declare their own byte offsets, so there + // is no schema for their paths to resolve against. Their offsets are covered instead by + // the per-property write tests in `tests/kamino`. + let Some(idl) = template.idl.as_ref() else { + continue; + }; for property in &template.properties { // constant_ref properties are UI dropdowns (e.g. token pickers), not // account fields, so they are not expected to resolve against the IDL. if property.is_constant_ref() { continue; } - if let Err(e) = surfpool_types::resolve_idl_type( - &template.idl, - &template.account_type, - &property.path, - ) { + checked += 1; + if let Err(e) = + surfpool_types::resolve_idl_type(idl, &template.account_type, &property.path) + { errors.push(format!("[{}] {}: {}", template.id, property.path, e)); } } @@ -1180,6 +1211,12 @@ mod tests { errors.len(), errors.join("\n ") ); + // Without this the skip above could silently swallow every template and the test would pass + // having resolved nothing. + assert!( + checked > 0, + "no property was resolved against an IDL, so this proved nothing" + ); } #[test] @@ -1339,7 +1376,7 @@ mod tests { ("ref_price.0", IdlType::U16), ] { let resolved = - surfpool_types::resolve_idl_type(&template.idl, &template.account_type, path) + surfpool_types::resolve_idl_type(template.idl(), &template.account_type, path) .unwrap_or_else(|e| panic!("{path} should resolve: {e}")); assert_eq!( *resolved, expected, @@ -1352,7 +1389,7 @@ mod tests { .get("kamino-obligation-positions") .expect("kamino-obligation-positions should exist"); let resolved = surfpool_types::resolve_idl_type( - &obligation.idl, + obligation.idl(), &obligation.account_type, "deposits.0.deposit_reserve", ) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index baff46c7..83143258 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -285,6 +285,13 @@ fn json_integer_digits(json: &serde_json::Value, target: &str) -> SurfpoolResult } } +/// The bundled template registry, parsed once and reused. +fn template_registry() -> &'static crate::scenarios::TemplateRegistry { + static REGISTRY: std::sync::OnceLock = + std::sync::OnceLock::new(); + REGISTRY.get_or_init(crate::scenarios::TemplateRegistry::new) +} + /// Converts JSON into a txtx [`Value`] using the expected IDL type fn json_to_txtx_value_for_idl_type( json: &serde_json::Value, @@ -841,7 +848,11 @@ impl SurfnetSvm { fn register_builtin_template_idls(&mut self) { let registry = TemplateRegistry::new(); for (_, template) in registry.templates.into_iter() { - let _ = self.register_idl(template.idl, None); + // Templates for programs with no IDL have nothing to register; they write through + // `raw_layout` instead. + if let Some(idl) = template.idl { + let _ = self.register_idl(idl, None); + } } } @@ -3065,6 +3076,50 @@ impl SurfnetSvm { continue; }; + // Programs with no usable IDL carry a byte layout instead, and this MUST come + // before the IDL lookup below: those programs have no registered IDL at all, so the + // lookup would `continue` and silently drop the override. + let raw_template = template_registry() + .get(&override_instance.template_id) + .filter(|t| t.raw_layout.is_some()) + .cloned(); + if let Some(template) = raw_template { + let raw_layout = template.raw_layout.expect("filtered above"); + let properties = template.properties; + match raw_layout.materialize( + account.data(), + &properties, + &account_values, + target_slot, + ) { + Ok(new_data) => { + let modified = Account { + lamports: account.lamports(), + data: new_data, + owner: *account.owner(), + executable: account.executable(), + rent_epoch: account.rent_epoch(), + }; + if let Err(e) = self.inner.set_account(account_pubkey, modified) { + warn!("Failed to set raw-layout account {}: {}", account_pubkey, e); + } else { + debug!( + "Raw-layout override {} applied {} field(s) to {}", + override_instance.id, + account_values.len(), + account_pubkey + ); + settled_this_slot.insert(account_pubkey); + } + } + Err(e) => warn!( + "Raw-layout override {} failed on {}: {}", + override_instance.id, account_pubkey, e + ), + } + continue; + } + // Mints fail the token unpack and keep flowing through the IDL path. if is_supported_token_program(account.owner()) { if let Ok(token_account) = TokenAccount::unpack(account.data()) { @@ -5809,10 +5864,18 @@ mod tests { assert!(!epoch_schedule.warmup); let registry = TemplateRegistry::new(); + let mut checked = 0usize; for (_, template) in registry.templates { - let program_id = template.idl.address.clone(); + // Templates for programs that publish no IDL have nothing to register. + let Some(idl) = template.idl else { continue }; + let program_id = idl.address.clone(); assert!(svm.registered_idls.get(&program_id).unwrap().is_some()); + checked += 1; } + assert!( + checked > 0, + "no template carried an IDL, so this proved nothing about registration" + ); assert!(svm.skip_blockhash_check); } diff --git a/crates/core/src/tests/bisonfi/mod.rs b/crates/core/src/tests/bisonfi/mod.rs new file mode 100644 index 00000000..dd344f0b --- /dev/null +++ b/crates/core/src/tests/bisonfi/mod.rs @@ -0,0 +1,3700 @@ +//! On-chain tests for BisonFi, and for the Orca Whirlpool leg its arbitrage scenario trades +//! against. +//! +//! The account-fetch and byte-diff helpers below are deliberately DUPLICATED from the Kamino suite +//! rather than shared. These suites fork live mainnet state and are the most likely place to need a +//! one-off change to retry behaviour or account synthesis; a shared helper would couple two +//! unrelated protocols' tests together and make such a change risky for both. + +//! +//! Like the Kamino suite these fetch real mainnet accounts rather than embedding captured copies, +//! so they need a network connection and are compiled only behind a feature: +//! +//! ```text +//! cargo test -p surfpool-core --features integration-tests bisonfi +//! ``` +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. +//! +//! What these cover that a unit test cannot: BisonFi publishes no IDL, so there is no schema to +//! check a synthetic account against. The only way to know an offset is right is to run the real +//! deployed program over real account state and watch the fill change. + +use std::collections::HashMap; + +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{GetAccountResult, remote::SurfnetRemoteClient, svm::SurfnetSvm}, +}; + +// ---------------------------------------------------------------- fetch/diff helpers + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; + +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +/// Fetches the accounts in one request, so every account returned is from the same slot. +async fn fetch(addresses: &[&str]) -> Vec> { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let pubkeys: Vec = addresses + .iter() + .map(|a| Pubkey::from_str_const(a)) + .collect(); + + // The public endpoint throttles and intermittently 503s, which has nothing to do with what these + // tests assert. Retry a few times with backoff so a transient refusal is not read as a failure. + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!( + "failed to fetch {addresses:?} from mainnet after {attempt} attempts: {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.data, + GetAccountResult::None(_) => { + panic!("{address} no longer exists on mainnet; the test needs a new address") + } + }) + .collect() +} + +/// Like [`fetch`] but reports absence instead of panicking. +/// +/// Needed for PDAs that are only created lazily. A Whirlpool tick array, for instance, does not exist +/// until someone provides liquidity in that range, so "missing" is a real answer about the market +/// rather than a stale address in the test. +async fn fetch_optional(addresses: &[&str]) -> Vec>> { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let pubkeys: Vec = addresses + .iter() + .map(|a| Pubkey::from_str_const(a)) + .collect(); + let results = client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + .expect("get_multiple_accounts"); + results + .into_iter() + .map(|r| match r { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => Some(account.data), + GetAccountResult::None(_) => None, + }) + .collect() +} + +/// Like [`fetch`] but keeps each account's owner instead of its data. +/// +/// Needed to tell a classic SPL mint from a Token-2022 one. Two of BisonFi's live markets quote a +/// Token-2022 base asset and refuse a swap with `Custom(60)` if handed classic token accounts, so a +/// replay harness that assumes one token program silently cannot exercise them. +async fn fetch_owners(addresses: &[Pubkey]) -> Vec { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!("failed to fetch owners after {attempt} attempts: {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.owner, + GetAccountResult::None(_) => panic!("{address} no longer exists on mainnet"), + }) + .collect() +} + +/// Byte indices at which two buffers differ. +fn diff_indices(a: &[u8], b: &[u8]) -> Vec { + a.iter() + .zip(b.iter()) + .enumerate() + .filter(|(_, (x, y))| x != y) + .map(|(i, _)| i) + .collect() +} + +/// Minimal initialised SPL token account (the 165-byte legacy layout). +fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut d = vec![0u8; 165]; + d[0..32].copy_from_slice(mint.as_ref()); + d[32..64].copy_from_slice(owner.as_ref()); + d[64..72].copy_from_slice(&amount.to_le_bytes()); + d[108] = 1; // AccountState::Initialized + d +} + +fn spl_amount(data: &[u8]) -> u64 { + u64::from_le_bytes(data[64..72].try_into().unwrap()) +} + +/// Like [`fetch`] but keeps each account's lamports. A wrapped-SOL vault's lamports are part of its +/// state, so overwriting them with a placeholder makes the runtime reject the transaction as +/// unbalanced on any path that pays out the base token. +async fn fetch_with_lamports(addresses: &[&str]) -> Vec<(Vec, u64)> { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let pubkeys: Vec = addresses + .iter() + .map(|a| Pubkey::from_str_const(a)) + .collect(); + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!("failed to fetch {addresses:?} after {attempt} attempts: {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.data, account.lamports) + } + GetAccountResult::None(_) => panic!("{address} no longer exists on mainnet"), + }) + .collect() +} + +const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + +// ------------------------------------------------------------------ BisonFi / Orca + +/// The token program owning each pool's base and quote mint, resolved for every pool in one request. +async fn bisonfi_token_programs(pools: &[Vec]) -> Vec<(Pubkey, Pubkey)> { + let mut mints: Vec = Vec::new(); + for data in pools { + for range in [184..216, 216..248] { + let m = Pubkey::new_from_array(data[range].try_into().unwrap()); + if !mints.contains(&m) { + mints.push(m); + } + } + } + let owners = fetch_owners(&mints).await; + let map: HashMap = mints.into_iter().zip(owners).collect(); + pools + .iter() + .map(|data| { + let base = Pubkey::new_from_array(data[184..216].try_into().unwrap()); + let quote = Pubkey::new_from_array(data[216..248].try_into().unwrap()); + (map[&base], map["e]) + }) + .collect() +} + +const BISONFI_POOL: &str = "8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo"; + +/// The reconstructed layout must describe every one of the 2048 bytes, or the re-encode silently +/// truncates or reorders the account. +#[tokio::test] +async fn bisonfi_pool_round_trips_unchanged() { + let data = fetch(&[BISONFI_POOL]).await.remove(0); + assert_eq!(data.len(), 2048, "BisonFi pool accounts are 2048 bytes"); + assert_eq!(&data[..8], b"POOLSTAT", "magic prefix"); + + let registry = TemplateRegistry::new(); + let template = registry + .get("bisonfi-fair-value") + .expect("bisonfi-fair-value template"); + let raw_layout = template + .raw_layout + .as_ref() + .expect("bisonfi templates carry a raw layout"); + + let forged = raw_layout + .materialize(&data, &template.properties, &HashMap::new(), 0) + .expect("live BisonFi pool should round-trip through the byte layout"); + + assert_eq!(forged.len(), data.len(), "size changed on round-trip"); + let diffs = diff_indices(&forged, &data); + assert!( + diffs.is_empty(), + "the reconstructed layout altered {} byte(s) on a no-op round-trip, first at {:?} - the \ + program was likely redeployed with a changed layout", + diffs.len(), + diffs.first() + ); +} + +/// The published mid is the only price lever, and it is a u128 far beyond `u64::MAX`, so it can +/// only be written as a decimal string. +#[tokio::test] +async fn bisonfi_fair_value_override_writes_expected_bytes() { + const FAIR_VALUE: usize = 832; + + let data = fetch(&[BISONFI_POOL]).await.remove(0); + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").unwrap(); + let raw_layout = template + .raw_layout + .as_ref() + .expect("bisonfi templates carry a raw layout"); + + // $50.00 scaled by 2^88 + let target: u128 = 50u128 * (1u128 << 88); + let forged = raw_layout + .materialize( + &data, + &template.properties, + &HashMap::from([( + "fair_value".to_string(), + serde_json::json!(target.to_string()), + )]), + 0, + ) + .expect("fair value override should apply"); + + assert_eq!( + u128::from_le_bytes(forged[FAIR_VALUE..FAIR_VALUE + 16].try_into().unwrap()), + target, + "the published mid must land at offset 832 as a 2^88 fixed point" + ); + let diffs = diff_indices(&forged, &data); + assert!( + diffs + .iter() + .all(|i| (FAIR_VALUE..FAIR_VALUE + 16).contains(i)), + "only the fair value should change, got {diffs:?}" + ); +} + +/// The size and magic guard is all that stands in for a discriminator, so it has to actually bite. +#[tokio::test] +async fn bisonfi_raw_layout_refuses_the_wrong_account() { + let data = fetch(&[BISONFI_POOL]).await.remove(0); + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").expect("template"); + let raw_layout = template.raw_layout.as_ref().expect("raw layout"); + + assert!(raw_layout.guard(&data).is_ok(), "the real pool must pass"); + + let mut wrong_magic = data.clone(); + wrong_magic[0] = b'X'; + let err = raw_layout + .guard(&wrong_magic) + .expect_err("a changed magic must be refused"); + assert!(err.contains("magic"), "unexpected error: {err}"); + + let err = raw_layout + .guard(&data[..2047]) + .expect_err("a differently sized account must be refused"); + assert!(err.contains("bytes"), "unexpected error: {err}"); +} + +/// last_update_slot is what makes the staleness scenario possible, so pin that it really is the +/// chain slot on a live market and that ageing it is a one-field write. +#[tokio::test] +async fn bisonfi_freshness_tracks_the_chain_slot() { + const LAST_UPDATE: usize = 72; + const PREVIOUS_UPDATE: usize = 80; + + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + let slot = client + .get_epoch_info() + .await + .expect("epoch info") + .absolute_slot; + let data = fetch(&[BISONFI_POOL]).await.remove(0); + + let last = u64::from_le_bytes(data[LAST_UPDATE..LAST_UPDATE + 8].try_into().unwrap()); + let prev = u64::from_le_bytes( + data[PREVIOUS_UPDATE..PREVIOUS_UPDATE + 8] + .try_into() + .unwrap(), + ); + assert!( + slot.saturating_sub(last) < 200, + "a live market should have been updated within the last ~200 slots; chain {slot}, \ + last_update {last}. If this market went dormant, pick another." + ); + // Not strict. The operator republishes about ten times a second against ~400ms slots, so two + // publications landing in one slot is normal and leaves these two fields EQUAL. Requiring prev to + // be strictly behind made this test fail intermittently on nothing more than a busy market; the + // property actually worth asserting is that previous never LEADS last. + assert!( + prev <= last, + "previous_update_slot ({prev}) must never lead last_update_slot ({last})" + ); + + let registry = TemplateRegistry::new(); + let template = registry + .get("bisonfi-freshness") + .expect("freshness template"); + let raw_layout = template.raw_layout.as_ref().expect("raw layout"); + + let aged = last - 1000; + let forged = raw_layout + .materialize( + &data, + &template.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::json!(aged))]), + 0, + ) + .expect("ageing the quote should apply"); + assert_eq!( + u64::from_le_bytes(forged[LAST_UPDATE..LAST_UPDATE + 8].try_into().unwrap()), + aged + ); + let diffs = diff_indices(&forged, &data); + assert!(!diffs.is_empty(), "the slot should have changed"); + assert!( + diffs + .iter() + .all(|i| (LAST_UPDATE..LAST_UPDATE + 8).contains(i)), + "only bytes within last_update_slot should change, got {diffs:?}" + ); +} + +/// Every account the program owns, live and dormant, as of program build 3f38e742. The templates +/// default to one market but nothing stops a scenario naming another, so the guard and the write +/// have to behave identically on all of them. +const BISONFI_ALL_POOLS: [&str; 17] = [ + "2vPjbPRnz7V1SLGr56CmLLc7JspzfSfccWp3Th5KbrMJ", + "6b5LxeDVxqCGAhZjjjgieGP71c5GBt2cBwiafCFX6NMU", + "8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo", + "AfaA4CE8C2DWSHANCqvU9RWrxRiXCV7KKVSw4cHi68Wn", + "DSzgmzz1Ms4qshdeCpE2uWenXXyNbkikw3bzfJRAv7JF", + "FJnaiidSLXFweWkgbinxEHRykVHsnkzDcYbNDR3RF5LN", + "GU7Auyn3cMxtuZX8N3ezhKztgJ1bqqpuUk19KWXqnYwv", + "Hv8FoJFsrQhoyrR6Lcz4KFcpqNHU1Kxj2yaFDKU6vJdp", + "7ZTpmqKWeAkRwHHgi74Gu1o6vWrHJRBDNsZTAdkKpohv", + "AWVYnCT2ZdLsWZf1X9KXatZhC2TyruRM22y8KZqVeupr", + "CKc2gypi1feWLboi7PWRgNTCi6NWhkYaGU4v6rhnZDDJ", + "4X3seJERbu4xy7sVAndPBsy4JWZVAGEpXv1NcCQ6zo66", + "Gsu4WmGJf9z4RWiQ9onE9u29rSvh5XsAkVwUJ2bLrGQb", + "51FQwjrvo8J8zXUaKyAznJ5NYpoiTCuqAqCu3HAMB9NZ", + "4XkEAUpmQnuKK2N1H73v68GkTpbNrxZZ37ZyfHfELZve", + "6U1kWANmyBuJRTZGRuPb9o2EJ6KRui3QpqrWDZoZ4bnG", + "FC9pWtfdtbyGZ5WHTLneoMSUx6jmTDgqKaxDcm2trsND", +]; + +/// AVAX-USDC-Pool1. Also 2048 bytes and also carries the POOLSTAT magic, but its version word is 2 +/// and its fields are not where the v3 layout says: offset 832 holds 2^32+1, not a price. It exists +/// to prove the guard refuses it. +const BISONFI_V2_POOL: &str = "9fLzyySS73UnecJRzx2AKcgoSQ1qigzU3b6m9e2iVq6"; + +/// The reconstruction has to describe all 2048 bytes of *every* v3 pool, not just the busy one the +/// templates point at. A dormant pool exercises regions the live pool leaves zeroed, so a field +/// boundary that is wrong in an unused region only shows up here. +#[tokio::test] +async fn bisonfi_every_pool_round_trips_unchanged() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").expect("template"); + let raw_layout = template + .raw_layout + .as_ref() + .expect("bisonfi templates carry a raw layout"); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + assert_eq!(data.len(), 2048, "{pool} should be 2048 bytes"); + assert_eq!(&data[..8], b"POOLSTAT", "{pool} magic prefix"); + + let forged = raw_layout + .materialize(data, &template.properties, &HashMap::new(), 0) + .unwrap_or_else(|e| panic!("{pool} failed to round-trip through the byte layout: {e}")); + let diffs = diff_indices(&forged, data); + assert!( + diffs.is_empty(), + "{pool}: the layout altered {} byte(s) on a no-op round-trip, first at {:?}", + diffs.len(), + diffs.first() + ); + } +} + +/// The write half of the matrix: each shipping property, against each of the 18 pools. Asserts the +/// guard admits the account, the value lands at the offset the template declares, and nothing +/// outside that field moves. +#[tokio::test] +async fn bisonfi_every_property_writes_cleanly_on_every_pool() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + + // (template id, property name, offset, width, value to write) + let cases: [(&str, &str, usize, usize, serde_json::Value); 4] = [ + ( + "bisonfi-fair-value", + "fair_value", + 832, + 16, + serde_json::json!((50u128 * (1u128 << 88)).to_string()), + ), + ( + "bisonfi-freshness", + "last_update_slot", + 72, + 8, + serde_json::json!(123_456_789u64), + ), + ( + "bisonfi-depth", + "base_reserve", + 48, + 8, + serde_json::json!(1_000_000_000u64), + ), + ( + "bisonfi-depth", + "quote_reserve", + 56, + 8, + serde_json::json!(2_000_000_000u64), + ), + ]; + + for (id, prop, offset, width, value) in cases { + let template = registry.get(id).unwrap_or_else(|| panic!("{id} template")); + let raw_layout = template + .raw_layout + .as_ref() + .unwrap_or_else(|| panic!("{id} carries a raw layout")); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + raw_layout + .guard(data) + .unwrap_or_else(|e| panic!("{id}: guard rejected {pool}: {e}")); + + let forged = raw_layout + .materialize( + data, + &template.properties, + &HashMap::from([(prop.to_string(), value.clone())]), + 0, + ) + .unwrap_or_else(|e| panic!("{id}: {prop} failed on {pool}: {e}")); + + assert_eq!(forged.len(), 2048, "{id} on {pool}: size changed"); + let diffs = diff_indices(&forged, data); + assert!( + diffs.iter().all(|i| (offset..offset + width).contains(i)), + "{id}: writing {prop} on {pool} touched bytes outside {offset}..{}: {diffs:?}", + offset + width + ); + // And the value actually landed. + let mut buf = [0u8; 16]; + buf[..width].copy_from_slice(&forged[offset..offset + width]); + let got = u128::from_le_bytes(buf); + let want: u128 = match &value { + serde_json::Value::String(s) => s.parse().unwrap(), + // A negative tick lands as two's complement in `width` bytes, so compare against + // the same truncation rather than treating the field as unsigned. + v => match v.as_i64() { + Some(n) if n < 0 => (n as i128 as u128) & ((1u128 << (width * 8)) - 1), + _ => v.as_u64().unwrap() as u128, + }, + }; + assert_eq!(got, want, "{id}: {prop} on {pool} did not land"); + } + } +} + +/// Offsets 48 and 56 mirror the vaults exactly, which is why no template writes them. This pins +/// that measurement so the claim in the layout docs cannot rot silently: if a redeploy changes it, +/// the reserve fields mean something else and the docs need revisiting. +#[tokio::test] +async fn bisonfi_reserves_mirror_the_vaults() { + const BASE_RESERVE: usize = 48; + const BASE_VAULT: usize = 120; + const QUOTE_VAULT: usize = 152; + + // The vaults are named in the pool itself, but the balance comparison is only meaningful if + // both are read at the same slot - this market turns over thousands of SOL in a few hundred + // slots. So they are fetched in one batch, which means the addresses have to be known up front + // and then checked against the pool's own fields. + const BASE_VAULT_ADDR: &str = "ATRsNGv2nDw7hSMfkUTBoVUDsFDwN7po7KbecyiGWNB4"; + const QUOTE_VAULT_ADDR: &str = "2Y7HATmn9aJBcxCskE5V2U2epmjvkZmB51zTJBbhj4cU"; + + let batch = fetch(&[BISONFI_POOL, BASE_VAULT_ADDR, QUOTE_VAULT_ADDR]).await; + let data = &batch[0]; + + assert_eq!( + Pubkey::new_from_array(data[BASE_VAULT..BASE_VAULT + 32].try_into().unwrap()), + Pubkey::from_str_const(BASE_VAULT_ADDR), + "base_vault at offset 120 no longer points at the expected token account" + ); + assert_eq!( + Pubkey::new_from_array(data[QUOTE_VAULT..QUOTE_VAULT + 32].try_into().unwrap()), + Pubkey::from_str_const(QUOTE_VAULT_ADDR), + "quote_vault at offset 152 no longer points at the expected token account" + ); + + // SPL token account: amount is a u64 at offset 64. + let base_held = u64::from_le_bytes(batch[1][64..72].try_into().unwrap()); + let cached = u64::from_le_bytes(data[BASE_RESERVE..BASE_RESERVE + 8].try_into().unwrap()); + + // Same slot, so they must agree exactly. This is the measurement that disqualified offset 48 + // as a "quotable slice" of the vaults: it is the whole balance, mirrored. + assert_eq!( + cached, base_held, + "offset 48 is expected to mirror the base vault balance exactly; pool says {cached}, \ + vault holds {base_held}" + ); +} + +/// The pools a behavioural scenario can actually be asserted on, fetched once. +struct BisonfiRig { + elf: Vec, + /// Address, account bytes, and the token program owning each side's mint. + quoting: Vec<(&'static str, Vec, (Pubkey, Pubkey))>, +} + +impl BisonfiRig { + /// Applies one template's values through the real override engine and replays a swap. + fn scenario( + &self, + pool: &str, + data: &[u8], + tp: (Pubkey, Pubkey), + template_id: &str, + values: &[(&str, serde_json::Value)], + amount_in: u64, + direction: u8, + ) -> u64 { + // Materialize INSIDE the replay, not before it. `bisonfi_replay` derives the simnet clock + // from the pool's own last_update_slot, so handing it an already-aged account moves the clock + // back along with the field and the quote never looks stale at all - which is exactly how the + // freshness scenario first appeared to fail. + self.try_scenario(pool, data, tp, template_id, values, amount_in, direction) + .unwrap_or_else(|e| panic!("{template_id} on {pool}: replay failed: {e}")) + } + + /// As [`Self::scenario`] but surfaces a refusal instead of panicking, for the scenarios where the + /// venue declining to fill is the point. + fn try_scenario( + &self, + pool: &str, + data: &[u8], + tp: (Pubkey, Pubkey), + template_id: &str, + values: &[(&str, serde_json::Value)], + amount_in: u64, + direction: u8, + ) -> Result { + let registry = TemplateRegistry::new(); + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("{template_id} must exist in the registry")); + let raw_layout = template + .raw_layout + .as_ref() + .unwrap_or_else(|| panic!("{template_id} must carry a raw layout")); + let map: HashMap = values + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + let props = template.properties.clone(); + let layout = raw_layout.clone(); + let target_slot = u64::from_le_bytes(data[72..80].try_into().expect("8 bytes")); + bisonfi_replay(&self.elf, pool, data, tp, amount_in, direction, move |d| { + let forged = layout + .materialize(d.as_slice(), &props, &map, target_slot) + .unwrap_or_else(|e| panic!("materialize failed: {e}")); + *d = forged; + }) + } + + /// A sell size that every live market fills, at 2% of the base reserve. + fn sell_size(data: &[u8]) -> u64 { + u64::from_le_bytes(data[48..56].try_into().unwrap()) / 50 + } + + /// The quote-side notional matching [`Self::sell_size`], taken from what a control sell actually + /// pays out. + /// + /// An earlier version derived this from the pool's fixed-point mid, which is the price in HUMAN + /// units - so it was out by the market's decimal shift, a thousand-fold on a 9/6 pair. Every buy + /// leg then asked for more than the venue would fill and was quietly skipped. Using the control + /// fill needs no decimal table and cannot drift. + fn buy_size(control_sell_out: u64) -> u64 { + control_sell_out + } +} + +/// One rig per process. Ten tests need it, and each build costs two `getMultipleAccounts` calls +/// against a public endpoint that rate-limits - running them in parallel exhausted it and failed six +/// tests at once, every one of which passed in isolation. +async fn bisonfi_rig() -> std::sync::Arc { + static CACHE: tokio::sync::OnceCell> = + tokio::sync::OnceCell::const_new(); + CACHE + .get_or_init(|| async { std::sync::Arc::new(bisonfi_rig_uncached().await) }) + .await + .clone() +} + +async fn bisonfi_rig_uncached() -> BisonfiRig { + let elf = bisonfi_elf().await; + let all = fetch(&BISONFI_ALL_POOLS).await; + let programs = bisonfi_token_programs(&all).await; + let mut quoting = Vec::new(); + for ((pool, data), tp) in BISONFI_ALL_POOLS + .iter() + .zip(all.iter()) + .zip(programs.iter()) + { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + if base_reserve < 1_000_000 { + continue; + } + let size = BisonfiRig::sell_size(data); + if size == 0 { + continue; + } + if let Ok(out) = bisonfi_replay(&elf, pool, data, *tp, size, 0, |_| {}) { + if out > 0 { + quoting.push((*pool, data.clone(), *tp)); + } + } + } + // Six of the seventeen v3 pools publish a current mid. Two more are live but quote a Token-2022 + // base asset the harness cannot build accounts for; the rest are dormant by 13-30 million slots + // and return no quote whatever is written to them. If this count drops, coverage silently + // narrowed and the scenario assertions below stop meaning anything. + assert!( + quoting.len() >= 6, + "only {} of {} pools can be quoted; scenario coverage has narrowed", + quoting.len(), + BISONFI_ALL_POOLS.len() + ); + BisonfiRig { elf, quoting } +} + +/// SCENARIO: set X mid price for a given market. +/// +/// The template's whole promise is that the number you pass becomes the price the venue quotes +/// around. Asserted as proportionality, on every market that quotes, because that is the property a +/// scenario author relies on: ask for double and the fill doubles. +#[tokio::test] +async fn bisonfi_scenario_set_mid_price() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let base = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control failed: {e}")); + + for (label, num, den) in [("double", 2u128, 1u128), ("half", 1, 2)] { + let out = rig.scenario( + pool, + data, + *tp, + "bisonfi-fair-value", + &[( + "fair_value", + serde_json::json!((mid * num / den).to_string()), + )], + size, + 0, + ); + let want = base as f64 * num as f64 / den as f64; + let err = (out as f64 - want) / want; + assert!( + err.abs() < 0.005, + "{pool}: setting the mid to {label} the live value paid {out}, {:.3}% off the {want:.0} \ + that proportionality requires. A scenario asking for a price would not get it", + err * 100.0 + ); + } + } +} + +/// SCENARIO: thin out a given market so a large trade slips measurably. +#[tokio::test] +async fn bisonfi_scenario_thin_depth_makes_a_trade_slip() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let at = |r: u64| { + rig.scenario( + pool, + data, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(r))], + size, + 0, + ) + }; + // Raising a reserve above the vault balance breaks settlement, which the template warns + // about, so "deep" is the live value and the comparison runs downward from there. + let deep = at(quote_reserve); + let thin = at(quote_reserve / 2); + let thinner = at(quote_reserve / 4); + assert!( + deep > thin && thin > thinner, + "{pool}: halving the payout reserve must make the same sell fill worse each time, got \ + {deep} -> {thin} -> {thinner}" + ); + } +} + +/// Asserts that a depth override moves the direction it is documented to move and leaves the +/// opposite direction alone. +/// +/// `cross` is the untargeted direction (value, control); `own` is the targeted one. Deliberately not +/// an equality check on `cross`. The claim the template makes - and the only one a consumer relies on +/// - is that each reserve constrains one direction. Exact byte-identity of the untargeted quote is a +/// strictly stronger claim, and it is not one this program guarantees: the working ladder at 288/1036 +/// is refreshed from 528/1196 through a watermark-gated memcpy, so a write that tips that gate can +/// shift both directions by a few bps without the documented asymmetry being wrong at all. That was +/// observed once in the wild - quartering base_reserve moved a sell 3.5 bps on DSzgmzz1 - and could +/// not be reproduced across a size sweep from 1/10000 of the reserve up to the whole of it, on any of +/// the six quoting markets, where the cross effect measured exactly 0.0000 bps. +/// +/// So the tolerance below is not slack for a claim we cannot prove. It asserts the asymmetry itself: +/// the untargeted direction must stay within 50 bps, AND the targeted direction must move at least +/// ten times further. A lever that genuinely bled into both directions fails the ratio even when both +/// moves are individually small, which is what exact equality was really there to catch. +fn assert_direction_specific( + pool: &str, + field: &str, + cross: (u64, u64), + own: (Result, u64), +) { + const CROSS_TOLERANCE: f64 = 0.005; // 50 bps + const MIN_RATIO: f64 = 10.0; + + let (cross_val, cross_control) = cross; + let cross_rel = (cross_val as f64 - cross_control as f64).abs() / cross_control as f64; + assert!( + cross_rel <= CROSS_TOLERANCE, + "{pool}: lowering {field} moved the direction it should not constrain by {:.2} bps ({cross_val} vs control {cross_control}). The template's direction guidance would be wrong", + cross_rel * 10_000.0 + ); + + // A refusal is an unboundedly large move on the targeted side, so the ratio is satisfied outright. + let (own_val, own_control) = own; + let own_rel = match own_val { + Err(_) => f64::INFINITY, + Ok(v) => (v as f64 - own_control as f64).abs() / own_control as f64, + }; + assert!( + own_rel >= cross_rel * MIN_RATIO, + "{pool}: lowering {field} moved the direction it constrains by {:.2} bps but moved the other direction by {:.2} bps. The two are within {MIN_RATIO}x, so this is not a direction-specific lever and the template's guidance would mislead", + own_rel * 10_000.0, + cross_rel * 10_000.0 + ); +} + +/// SCENARIO: make a given market expensive in one direction only. +/// +/// The pool pays out of one side, so lowering that side's reserve must hurt trades in that direction +/// and leave the other direction untouched. A router that treats the venue as symmetric fails here. +#[tokio::test] +async fn bisonfi_scenario_one_sided_liquidity() { + let rig = bisonfi_rig().await; + // Starve hard rather than gently. Quartering a reserve barely binds when the trade is only 2% of + // it: on DSzgmzz1 a quartered base_reserve moved the buy it constrains by 0.5 bps while the ladder + // refresh wobbled the sell by 2.7 bps, so the asymmetry was smaller than the noise and the ratio + // below could not see it. Starving by 1000x drives the constrained direction to the point where + // the reserve genuinely limits the fill, which is the regime the template's guidance describes. + const STARVE: u64 = 1000; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let sell = BisonfiRig::sell_size(data); + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let sell_control = bisonfi_replay(&rig.elf, pool, data, *tp, sell, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: sell control failed: {e}")); + let buy = BisonfiRig::buy_size(sell_control); + let buy_control = bisonfi_replay(&rig.elf, pool, data, *tp, buy, 1, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: buy control failed: {e}")); + assert!(buy_control > 0, "{pool}: buy control returned nothing"); + checked += 1; + + // A refusal counts as strictly worse than any fill - the venue declining is the extreme end + // of the same lever, and starving a reserve hard enough reaches it. + let worse_than = |r: Result, control: u64| match r { + Ok(o) => o < control, + Err(_) => true, + }; + + // Starve the quote side: sells get worse, the opposite direction barely moves. + let vals = [("quote_reserve", serde_json::json!(quote_reserve / STARVE))]; + let sell_starved = rig.try_scenario(pool, data, *tp, "bisonfi-depth", &vals, sell, 0); + let buy_cross = rig.scenario(pool, data, *tp, "bisonfi-depth", &vals, buy, 1); + assert!( + worse_than(sell_starved.clone(), sell_control), + "{pool}: lowering quote_reserve must make a SELL worse, got {sell_starved:?} vs \ + {sell_control}" + ); + assert_direction_specific( + pool, + "quote_reserve", + (buy_cross, buy_control), + (sell_starved.clone(), sell_control), + ); + + // And the mirror image on the base side. + let vals = [("base_reserve", serde_json::json!(base_reserve / STARVE))]; + let buy_starved = rig.try_scenario(pool, data, *tp, "bisonfi-depth", &vals, buy, 1); + let sell_cross = rig.scenario(pool, data, *tp, "bisonfi-depth", &vals, sell, 0); + assert!( + worse_than(buy_starved.clone(), buy_control), + "{pool}: lowering base_reserve must make a BUY worse, got {buy_starved:?} vs \ + {buy_control}" + ); + assert_direction_specific( + pool, + "base_reserve", + (sell_cross, sell_control), + (buy_starved.clone(), buy_control), + ); + } + // Both directions must actually have been exercised. The buy leg used to be skipped on every + // market because the notional was computed wrongly, and nothing said so. + assert!( + checked >= 6, + "only {checked} markets exercised both directions of the depth template" + ); +} + +/// SCENARIO: silence a given market maker so it stops quoting entirely, and the boundary case where +/// it is one slot behind and still quotes. +/// +/// This is the behaviour no constant-product AMM can imitate - an AMM always quotes something - so +/// it is the scenario most likely to be untested on the consuming side. +#[tokio::test] +async fn bisonfi_scenario_silence_the_maker() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let published = u64::from_le_bytes(data[72..80].try_into().unwrap()); + + // One slot behind: still quoting. This is the boundary, and it is why the template says the + // tolerance is one slot rather than "recent". + let boundary = rig.scenario( + pool, + data, + *tp, + "bisonfi-freshness", + &[("last_update_slot", serde_json::json!(-1))], + size, + 0, + ); + assert!( + boundary > 0, + "{pool}: a quote one slot behind must still fill, or the boundary scenario is wrong" + ); + + // Two or more slots behind: silent. Checked well past the cliff as well as just over it, so + // a scenario that ages a market by a thousand slots is covered too. + for back in [2u64, 1_000, 1_000_000] { + let silent = rig.scenario( + pool, + data, + *tp, + "bisonfi-freshness", + &[("last_update_slot", serde_json::json!(-(back as i64)))], + size, + 0, + ); + assert_eq!( + silent, 0, + "{pool}: aged by {back} slots the venue must not fill at all, got {silent}" + ); + } + + // And the sharp part: the swap's minimum-output bound is NOT honoured on the stale path, so + // the caller gets a CONFIRMED transaction that moved nothing and ignored their slippage + // protection. The healthy control below proves the bound is otherwise real, so this is the + // program returning early rather than the harness failing to set the field. + let healthy = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control failed: {e}")); + assert!( + bisonfi_replay_min_out(&rig.elf, pool, data, *tp, size, healthy + 1, 0, |_| {}) + .is_err(), + "{pool}: a minimum above the fillable amount must revert on a healthy market, or the \ + bound is not a slippage guard at all and the claim below means nothing" + ); + let ignored = bisonfi_replay_min_out( + &rig.elf, + pool, + data, + *tp, + size, + healthy, + 0, + move |d: &mut Vec| { + d[72..80].copy_from_slice(&(published - 2).to_le_bytes()); + }, + ); + assert_eq!( + ignored, + Ok(0), + "{pool}: a silenced market must succeed with zero even when the caller demands \ + {healthy} out - if this ever starts reverting, the scenario's symptom changed from a \ + silent no-op to a failed transaction and every consumer's handling changes with it" + ); + } +} + +/// SCENARIO: make a given market unable to fill a trade at all. +/// +/// The extreme end of the depth lever: starve the payout reserve far enough and the program stops +/// negotiating and refuses, rather than quoting a terrible price. That is a distinct thing for a +/// router to handle - it has to split the trade or fall back to another venue - so it is asserted +/// separately from ordinary slippage. +#[tokio::test] +async fn bisonfi_scenario_market_cannot_fill() { + let rig = bisonfi_rig().await; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + // Escalate until the venue gives up. Which divisor does it depends on how much of the pool's + // depth the trade draws, so the claim is that SOME reachable setting refuses, not a + // particular number. + let mut refused_at = None; + for div in [4u64, 10, 100, 1_000, 100_000] { + let r = rig.try_scenario( + pool, + data, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(quote_reserve / div))], + size, + 0, + ); + match r { + Err(_) => { + refused_at = Some(div); + break; + } + Ok(0) => { + refused_at = Some(div); + break; + } + Ok(_) => {} + } + } + assert!( + refused_at.is_some(), + "{pool}: no reduction of quote_reserve down to a hundred-thousandth made the venue \ + refuse the trade, so the 'cannot fill' scenario is not reachable on this market" + ); + } +} + +/// Every property of the spread template, and the tick offsets each one is supposed to cover. +const BISONFI_SPREAD_PROPS: [(&str, usize, usize); 8] = [ + ("working_levels.0.tick_offset", 300, 4), + ("working_levels.4.tick_offset", 364, 4), + ("configured_levels.0.tick_offset", 540, 4), + ("configured_levels.4.tick_offset", 604, 4), + ("continuation_levels.0.tick_offset", 1048, 5), + ("continuation_levels.5.tick_offset", 1128, 5), + ("continuation_source_levels.0.tick_offset", 1208, 5), + ("continuation_source_levels.5.tick_offset", 1288, 5), +]; + +/// The bid half of the spread template's properties, all set to `v`. +/// +/// The bid runs are the ones starting at rung 0 of each region; the ask runs start mid-region. Both +/// halves are needed to move a two-sided book, but a sell only pays the bid side, so tests that +/// measure a sell set just these. +fn bisonfi_spread_bids(v: i32) -> Vec<(&'static str, serde_json::Value)> { + BISONFI_SPREAD_PROPS + .iter() + .filter(|(path, _, _)| path.contains(".0.")) + .map(|(path, _, _)| (*path, serde_json::json!(v))) + .collect() +} + +/// Builds a mutation closure that applies a shipped template through the real `materialize` path. +/// +/// For tests that iterate the raw pool list directly instead of going through `BisonfiRig`, so that +/// they still exercise the template we ship rather than a hand-written copy of its offsets. +fn bisonfi_apply_template( + template_id: &str, + values: &[(&str, serde_json::Value)], +) -> impl FnOnce(&mut Vec) + use<> { + let id = template_id.to_string(); + let registry = TemplateRegistry::new(); + let template = registry + .get(template_id) + .unwrap_or_else(|| panic!("{template_id} must exist in the registry")); + let layout = template + .raw_layout + .as_ref() + .unwrap_or_else(|| panic!("{template_id} must carry a raw layout")) + .clone(); + let props = template.properties.clone(); + let map: HashMap = values + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + move |d: &mut Vec| { + *d = layout + .materialize(d.as_slice(), &props, &map, 0) + .unwrap_or_else(|e| panic!("{id}: materialize failed: {e}")); + } +} + +/// Values setting the whole book to one magnitude: bid properties negative, ask properties positive. +fn bisonfi_spread_values(magnitude: i32) -> Vec<(&'static str, serde_json::Value)> { + BISONFI_SPREAD_PROPS + .iter() + .map(|(path, _, _)| { + // The bid properties are the ones whose run starts at the first rung of a region. + let is_bid = path.contains(".0."); + let v = if is_bid { + -magnitude.abs() + } else { + magnitude.abs() + }; + (*path, serde_json::json!(v)) + }) + .collect() +} + +/// SCENARIO: set X spread for a given market. +/// +/// The decisive form of the claim, and the one three earlier attempts got wrong by comparing spread +/// *differences* - which is blind to a change that shifts both legs equally. This compares two uniform +/// settings against each other, so the ratio is fully determined by the unit: +/// +/// price(T) = mid * (1 - T/2_560_000) => price(T1)/price(T2) = (1 - T1/u) / (1 - T2/u) +/// +/// Any multiplicative term the venue applies regardless - its base spread, a fee - cancels in that +/// ratio, and no per-market token decimals enter it either. So it tests the unit absolutely with +/// nothing fitted. +#[tokio::test] +async fn bisonfi_scenario_set_spread() { + const UNIT: f64 = 2_560_000.0; + const TIGHT: i32 = 2_560; // 10 bps + const WIDE: i32 = 25_600; // 1% + let predicted = (1.0 - WIDE as f64 / UNIT) / (1.0 - TIGHT as f64 / UNIT); + + let rig = bisonfi_rig().await; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + // 5% of the reserve: comfortably above the size below which the ladder is not consulted. + let size = base_reserve / 20; + let at = |magnitude: i32| { + rig.try_scenario( + pool, + data, + *tp, + "bisonfi-spread", + &bisonfi_spread_values(magnitude), + size, + 0, + ) + }; + let (tight, wide) = match (at(TIGHT), at(WIDE)) { + (Ok(t), Ok(w)) if t > 0 && w > 0 => (t, w), + _ => continue, + }; + assert!( + wide < tight, + "{pool}: a 1% ladder must pay the seller less than a 10 bps one, got {wide} vs {tight}" + ); + let ratio = wide as f64 / tight as f64; + let err = (ratio - predicted).abs() / predicted; + assert!( + err < 0.01, + "{pool}: going from a {TIGHT} tick to a {WIDE} tick changed the fill by a factor of \ + {ratio:.6}, but the 1/2,560,000 unit the template documents requires {predicted:.6} \ + ({:.3}% off). Either the unit is wrong or not every region is being written", + err * 100.0 + ); + checked += 1; + } + assert!( + checked >= 6, + "only {checked} markets exercised the spread template; the claim needs the live markets" + ); +} + +/// SCENARIO: quote wide on one side only. +/// +/// The parity test for the spread template, matching what `bisonfi_scenario_one_sided_liquidity` does +/// for depth. Bid offsets price sells and ask offsets price buys, so widening one side must leave the +/// other untouched. A template whose bid and ask offsets were transposed would still widen a quote and +/// would pass every test that only looks at one direction. +#[tokio::test] +async fn bisonfi_scenario_spread_is_side_specific() { + const WIDE: i32 = 25_600; // 1% + let bids: Vec<(&str, serde_json::Value)> = BISONFI_SPREAD_PROPS + .iter() + .filter(|(p, _, _)| p.contains(".0.")) + .map(|(p, _, _)| (*p, serde_json::json!(-WIDE))) + .collect(); + let asks: Vec<(&str, serde_json::Value)> = BISONFI_SPREAD_PROPS + .iter() + .filter(|(p, _, _)| !p.contains(".0.")) + .map(|(p, _, _)| (*p, serde_json::json!(WIDE))) + .collect(); + + let rig = bisonfi_rig().await; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + // No single trade size works everywhere: DSzgmzz1 does not consult the ladder below 5% of its + // reserve, and AfaA4CE8 cannot fill 5% at all. So the size is chosen per market - the first + // that fills both directions AND actually engages the ladder. + let usable = [20u64, 50, 100, 200, 1000].into_iter().find_map(|div| { + let sell = base_reserve / div; + let sell_control = bisonfi_replay(&rig.elf, pool, data, *tp, sell, 0, |_| {}).ok()?; + if sell_control == 0 { + return None; + } + let buy = BisonfiRig::buy_size(sell_control); + let buy_control = bisonfi_replay(&rig.elf, pool, data, *tp, buy, 1, |_| {}).ok()?; + if buy_control == 0 { + return None; + } + // The ladder has to bite at this size, or the assertions below are vacuous. + let probe = rig + .try_scenario(pool, data, *tp, "bisonfi-spread", &bids, sell, 0) + .ok()?; + (probe < sell_control).then_some((sell, sell_control, buy, buy_control)) + }); + let Some((sell, sell_control, buy, buy_control)) = usable else { + continue; + }; + + // Widening the bid must hurt sells and leave buys exactly where they were. + let sell_wide = rig.scenario(pool, data, *tp, "bisonfi-spread", &bids, sell, 0); + let buy_untouched = rig.scenario(pool, data, *tp, "bisonfi-spread", &bids, buy, 1); + assert!( + sell_wide < sell_control, + "{pool}: widening the BID must pay a seller less, got {sell_wide} vs {sell_control}" + ); + assert_eq!( + buy_untouched, buy_control, + "{pool}: widening the BID must not change a BUY. If this fires the bid and ask offsets \ + are transposed in the template" + ); + + // And the mirror image. + let buy_wide = rig.scenario(pool, data, *tp, "bisonfi-spread", &asks, buy, 1); + let sell_untouched = rig.scenario(pool, data, *tp, "bisonfi-spread", &asks, sell, 0); + assert!( + buy_wide < buy_control, + "{pool}: widening the ASK must give a buyer less base, got {buy_wide} vs {buy_control}" + ); + assert_eq!( + sell_untouched, sell_control, + "{pool}: widening the ASK must not change a SELL" + ); + checked += 1; + } + assert!( + checked >= 4, + "only {checked} markets exercised both directions of the spread template" + ); +} + +/// The write half: each property must set exactly its own run of tick fields and nothing else. +/// +/// A strided encoding writes several disjoint four-byte spans, so "nothing outside the field moved" is +/// a different assertion from every other property in this protocol - and getting it wrong would mean +/// silently overwriting a rung's share or level. +#[tokio::test] +async fn bisonfi_spread_template_writes_only_its_tick_fields() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-spread").expect("spread template"); + let raw_layout = template.raw_layout.as_ref().expect("raw layout"); + + for (path, offset, count) in BISONFI_SPREAD_PROPS { + let expected: Vec = (0..count) + .flat_map(|i| { + let at = offset + i * BISONFI_RUNG; + at..at + 4 + }) + .collect(); + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + let forged = raw_layout + .materialize( + data, + &template.properties, + &HashMap::from([(path.to_string(), serde_json::json!(-12_345i32))]), + 0, + ) + .unwrap_or_else(|e| panic!("{path} on {pool}: {e}")); + assert_eq!(forged.len(), 2048, "{path} on {pool}: size changed"); + for i in diff_indices(&forged, data) { + assert!( + expected.contains(&i), + "{path} on {pool}: byte {i} changed, outside the {count} tick fields at \ + {offset} stride 16. A strided write must not touch a rung's share or level" + ); + } + // And every slot in the run actually received the value. + for i in 0..count { + let at = offset + i * BISONFI_RUNG; + let got = i32::from_le_bytes(forged[at..at + 4].try_into().unwrap()); + assert_eq!( + got, -12_345, + "{path} on {pool}: rung {i} at offset {at} did not receive the value" + ); + } + } + } +} + +/// The live Orca Whirlpool SOL/USDC market, used as the AMM side of the arbitrage scenario. +const WHIRLPOOL_SOL_USDC: &str = "HJPjoWUrhoZzkNfRpHuieeFk9WcZWjwy6PBjZ81ngndJ"; + +/// SCENARIO: arbitrage between BisonFi and an AMM on the same pair. +/// +/// Surfpool forks mainnet, so dislocating BisonFi alone creates a real arbitrage against every other +/// venue's live state - no second override needed. This measures that against Orca's actual on-chain +/// price rather than a hardcoded number. +/// +/// The Whirlpool's price comes from its `sqrt_price` (Q64.64 at offset 65), squared. Nothing is +/// executed on the AMM side: pricing an Orca swap needs its tick arrays, which is a much larger piece +/// of harness. What this proves is that the override produces a dislocation that is real, correctly +/// signed, and of the right size against a live competing venue - which is what a router would act on. +/// +/// It is also self-validating: the first assertion is that both venues agree on the price BEFORE any +/// override. If the Whirlpool layout were misread, or the pair mismatched, that would fail rather than +/// silently making the arbitrage numbers meaningless. +#[tokio::test] +async fn bisonfi_scenario_arbitrage_against_an_amm() { + let rig = bisonfi_rig().await; + let (pool, data, tp) = rig + .quoting + .iter() + .find(|(p, _, _)| *p == BISONFI_POOL) + .expect("the WSOL/USDC market must be quoting for this scenario"); + + let whirlpool = fetch(&[WHIRLPOOL_SOL_USDC]).await.remove(0); + // Confirm the two venues really are the same pair and the same way round, so the comparison below + // is between like and like. + let (bisonfi_base, bisonfi_quote) = (&data[184..216], &data[216..248]); + assert_eq!( + &whirlpool[101..133], + bisonfi_base, + "the Whirlpool's token A must be BisonFi's base mint" + ); + assert_eq!( + &whirlpool[181..213], + bisonfi_quote, + "the Whirlpool's token B must be BisonFi's quote mint" + ); + + // Whirlpool price, in quote smallest-units per base smallest-unit. Squaring a Q64.64 needs care: + // done in f64 after the shift, which is ample for a comparison at this tolerance. + let sqrt_price = u128::from_le_bytes(whirlpool[65..81].try_into().unwrap()); + let amm_price = (sqrt_price as f64 / 2f64.powi(64)).powi(2); + assert!( + amm_price > 0.0, + "the Whirlpool must carry a live sqrt_price, got {sqrt_price}" + ); + + // BisonFi's realized price on the same basis: quote received per base sold. + let size = u64::from_le_bytes(data[48..56].try_into().unwrap()) / 100; + let realized = |image: Option>| -> f64 { + let out = match image { + None => bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}), + Some(img) => bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, move |d| *d = img), + } + .unwrap_or_else(|e| panic!("replay failed: {e}")); + assert!( + out > 0, + "BisonFi must fill for the comparison to mean anything" + ); + out as f64 / size as f64 + }; + + // 1. Undislocated, the two venues must agree. A proprietary market maker that disagreed with the + // largest AMM on SOL by more than a fraction of a percent would be arbitraged instantly. + let quiet = realized(None); + let disagreement = (quiet - amm_price).abs() / amm_price; + assert!( + disagreement < 0.02, + "BisonFi and Orca should price SOL within 2% of each other before any override; got \ + {quiet:.9} against {amm_price:.9} ({:.3}% apart). Either a layout offset is wrong or one \ + venue is not live", + disagreement * 100.0 + ); + + // 2. Now dislocate BisonFi upward by 10% through the shipped template, and the arbitrage appears: + // buy SOL on Orca, sell it to BisonFi. + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let registry = TemplateRegistry::new(); + let template = registry.get("bisonfi-fair-value").expect("template"); + let layout = template.raw_layout.as_ref().expect("layout"); + let dislocated = layout + .materialize( + data, + &template.properties, + &HashMap::from([( + "fair_value".to_string(), + serde_json::json!((mid * 11 / 10).to_string()), + )]), + 0, + ) + .expect("price override"); + let rich = realized(Some(dislocated)); + + let edge = (rich - amm_price) / amm_price; + assert!( + rich > quiet, + "the dislocated market must pay more than the quiet one, got {rich:.9} vs {quiet:.9}" + ); + assert!( + edge > 0.05, + "a 10% dislocation should leave at least 5% of edge against the AMM after BisonFi's own \ + spread and slippage; got {:.3}%", + edge * 100.0 + ); + assert!( + edge < 0.11, + "the edge cannot exceed the 10% dislocation that created it; got {:.3}%, which would mean \ + the price override is scaling by more than it was asked to", + edge * 100.0 + ); +} + +/// SCENARIO: the maker goes dark BETWEEN the quote and the fill. +/// +/// This is the one that needed a real gap closing. Every other scenario applies its override once and +/// asks what the program does. This one registers a scenario whose state CHANGES across slots, runs +/// the scheduler slot by slot, and then feeds each slot's account image to the deployed program. +/// +/// Why it matters: on Solana there is a gap of one or two slots between reading a price and the +/// transaction executing. If the maker stops publishing inside that window, a caller who did +/// everything right still gets no fill - and, as `bisonfi_scenario_silence_the_maker` shows, no error +/// either. Reproducing that needs the override to fire on a LATER slot than the one quoted on, which +/// exercises `register_scenario` and `materialize_overrides_for_slot` rather than a single write. +#[tokio::test] +async fn bisonfi_scenario_maker_goes_dark_between_quote_and_fill() { + use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + + const BASE_SLOT: u64 = 1_000_000; + const QUOTE_AT: u64 = 0; // scenario-relative slot the caller quotes on + const FILL_AT: u64 = 2; // and the slot the transaction actually lands on + + let rig = bisonfi_rig().await; + let (pool, data, tp) = rig.quoting.first().expect("a quoting market"); + let pool_key = pool.parse::().expect("pool address"); + let size = BisonfiRig::sell_size(data); + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account( + pool_key, + solana_account::Account { + lamports: 1_000_000, + data: data.clone(), + owner: Pubkey::from_str_const(BISONFI_PROGRAM), + executable: false, + rent_epoch: 0, + }, + ) + .expect("seed the pool account"); + + // Two steps on the same field: quoting normally when the caller looks, dark when it lands. + let mut scenario = Scenario::new( + "BisonFi maker goes dark mid-flight".to_string(), + "Quotes normally at the slot the caller prices on, then stops publishing before the \ + transaction executes" + .to_string(), + ); + for (relative, value) in [(QUOTE_AT, 0i64), (FILL_AT, -5i64)] { + scenario.add_override( + OverrideInstance::new( + "bisonfi-freshness".to_string(), + relative, + AccountAddress::Pubkey(pool_key.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::json!(value), + )])), + ); + } + svm.register_scenario(scenario, Some(BASE_SLOT)) + .expect("register scenario"); + + // Walk the slots and capture what the account looks like at each one. + let mut images: HashMap> = HashMap::new(); + for slot in BASE_SLOT..=BASE_SLOT + FILL_AT { + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + let account = svm + .inner + .get_account(&pool_key) + .expect("get_account") + .expect("account present"); + images.insert(slot, account.data); + } + + let field_at = |slot: u64| u64::from_le_bytes(images[&slot][72..80].try_into().unwrap()); + // The lead is resolved against the slot each step materializes at, so the first step stamps + // its own slot and the second lands five behind its own. + assert_eq!( + field_at(BASE_SLOT), + BASE_SLOT, + "at the quoting slot the venue must still be publishing" + ); + assert_eq!( + field_at(BASE_SLOT + 1), + BASE_SLOT, + "no override is scheduled for the intermediate slot, so the account must be untouched" + ); + assert_eq!( + field_at(BASE_SLOT + FILL_AT), + BASE_SLOT + FILL_AT - 5, + "the second step must have fired by the slot the transaction lands on" + ); + + // Now the half that makes this more than a scheduling test: hand each slot's image to the real + // program. The clock is taken from the ORIGINAL account, so the simnet's notion of "now" stays at + // the publication slot while the field moves underneath it - which is what actually happens when + // the maker stops and the chain moves on. + let replay = |image: Vec| { + bisonfi_replay( + &rig.elf, + pool, + data, + *tp, + size, + 0, + move |d: &mut Vec| { + *d = image; + }, + ) + }; + let quoted = replay(images[&BASE_SLOT].clone()).expect("the quoting slot must fill"); + assert!( + quoted > 0, + "the caller's quote has to be real, or the scenario proves nothing" + ); + let filled = replay(images[&(BASE_SLOT + FILL_AT)].clone()); + assert_eq!( + filled, + Ok(0), + "the maker went dark between the quote and the fill, so the swap must return nothing - and \ + it must do so without erroring, which is what makes this a silent failure" + ); +} + +/// SCENARIO: the mid MOVES between the quote and the fill - adverse selection. +/// +/// The other half of the mid-flight pair, and the contrast is the point. When the maker goes dark the +/// swap silently returns zero. When the maker simply reprices against the taker, the caller's own +/// minimum-output bound catches it and the transaction REVERTS. Same timing, same mechanism, two +/// completely different things for a consumer to handle - one detectable, one not. +#[tokio::test] +async fn bisonfi_scenario_mid_moves_between_quote_and_fill() { + use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + + const BASE_SLOT: u64 = 2_000_000; + const FILL_AT: u64 = 2; + + let rig = bisonfi_rig().await; + let (pool, data, tp) = rig.quoting.first().expect("a quoting market"); + let pool_key = pool.parse::().expect("pool address"); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let size = BisonfiRig::sell_size(data); + let moved = mid * 9 / 10; // the maker marks the asset down 10% while the taker is in flight + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account( + pool_key, + solana_account::Account { + lamports: 1_000_000, + data: data.clone(), + owner: Pubkey::from_str_const(BISONFI_PROGRAM), + executable: false, + rent_epoch: 0, + }, + ) + .expect("seed the pool account"); + + let mut scenario = Scenario::new( + "BisonFi reprices mid-flight".to_string(), + "Quotes one price at the slot the caller prices on and a worse one before the transaction \ + executes" + .to_string(), + ); + for (relative, value) in [(0u64, mid), (FILL_AT, moved)] { + scenario.add_override( + OverrideInstance::new( + "bisonfi-fair-value".to_string(), + relative, + AccountAddress::Pubkey(pool_key.to_string()), + ) + .with_values(HashMap::from([( + "fair_value".to_string(), + serde_json::json!(value.to_string()), + )])), + ); + } + svm.register_scenario(scenario, Some(BASE_SLOT)) + .expect("register scenario"); + + let mut images: HashMap> = HashMap::new(); + for slot in BASE_SLOT..=BASE_SLOT + FILL_AT { + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + images.insert( + slot, + svm.inner + .get_account(&pool_key) + .expect("get_account") + .expect("account present") + .data, + ); + } + let mid_at = |slot: u64| u128::from_le_bytes(images[&slot][832..848].try_into().unwrap()); + assert_eq!( + mid_at(BASE_SLOT), + mid, + "the quoting slot must carry the quoted price" + ); + assert_eq!( + mid_at(BASE_SLOT + FILL_AT), + moved, + "the repricing step must have fired by the slot the transaction lands on" + ); + + // What the caller quoted, and therefore the minimum they would sign for. + let quoted = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let image = images[&BASE_SLOT].clone(); + move |d: &mut Vec| *d = image + }) + .expect("the quoting slot must fill"); + assert!(quoted > 0, "the caller's quote has to be real"); + + // The same transaction, landing after the reprice. Without a minimum it fills at the worse price; + // with the minimum the caller actually quoted, it reverts. + let image = images[&(BASE_SLOT + FILL_AT)].clone(); + let unprotected = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }) + .expect("a repriced market still quotes, just worse"); + assert!( + unprotected < quoted, + "a 10% markdown must pay the seller less: got {unprotected} against a quote of {quoted}" + ); + + let protected = bisonfi_replay_min_out(&rig.elf, pool, data, *tp, size, quoted, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }); + assert!( + protected.is_err(), + "signing for the price that was quoted must REVERT once the maker has repriced, got \ + {protected:?}. This is the case a consumer can actually detect, unlike a dark maker" + ); +} + +/// SCENARIO: a dislocated price behind thin depth, so an arbitrage looks profitable at the quoted +/// mid and is worth materially less once the trade is actually filled. +/// +/// This is the composition of two templates in one scenario, and it is the one that catches a +/// consumer whose price-impact model is wrong rather than one that simply misreads a price. +#[tokio::test] +async fn bisonfi_scenario_dislocated_price_behind_thin_depth() { + let rig = bisonfi_rig().await; + let mut checked = 0usize; + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let dislocated = mid * 11 / 10; // the venue claims 10% above the market + + let control = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control failed: {e}")); + // Price alone: the full 10% should show up in the fill. + let price_only = rig.scenario( + pool, + data, + *tp, + "bisonfi-fair-value", + &[("fair_value", serde_json::json!(dislocated.to_string()))], + size, + 0, + ); + // Now the same dislocation with the payout side starved. Both templates target the same + // account, so the scenario applies them together. + let registry = TemplateRegistry::new(); + let layout = registry + .get("bisonfi-fair-value") + .and_then(|t| t.raw_layout.clone()) + .expect("layout"); + let priced = layout + .materialize( + data, + ®istry.get("bisonfi-fair-value").unwrap().properties, + &HashMap::from([( + "fair_value".to_string(), + serde_json::json!(dislocated.to_string()), + )]), + 0, + ) + .expect("price override"); + let both = rig.scenario( + pool, + &priced, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(quote_reserve / 4))], + size, + 0, + ); + + assert!( + price_only > control, + "{pool}: a 10% higher mid must pay more, got {price_only} vs {control}" + ); + assert!( + both < price_only, + "{pool}: starving the payout side must claw back part of the dislocation; the arb has \ + to look better on paper ({price_only}) than it fills ({both})" + ); + assert!( + both > control, + "{pool}: the dislocation should still be worth something after slippage, got {both} vs \ + {control}" + ); + checked += 1; + } + assert!( + checked >= 6, + "only {checked} markets exercised the combined scenario" + ); +} + +/// The spread template writes fixed offsets - 540 for level 1 of the bid, 604 for level 1 of the ask +/// and so on - which is only correct if every pool lays its ladder out the same way. This asserts the +/// invariant the template depends on, so a pool that ordered its rungs differently would fail here +/// rather than silently take a price offset meant for the other side of the book. +#[tokio::test] +async fn bisonfi_ladder_layout_is_uniform_across_every_pool() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let i32_at = |d: &[u8], o: usize| i32::from_le_bytes(d[o..o + 4].try_into().unwrap()); + let u32_at = |d: &[u8], o: usize| u32::from_le_bytes(d[o..o + 4].try_into().unwrap()); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + for table in [BISONFI_LADDER, BISONFI_LADDER_MIRROR] { + let mut ask_share_total = 0u64; + for rung in 0..8usize { + let o = table + rung * BISONFI_RUNG; + let level = i32_at(data, o + 8); + let tick = i32_at(data, o + 12); + // Rungs 0..3 are the bid side at levels -1..-4, rungs 4..7 the ask side at 1..4. + let expected = if rung < 4 { + -(rung as i32 + 1) + } else { + rung as i32 - 3 + }; + assert_eq!( + level, expected, + "{pool} table {table} rung {rung}: level is {level}, expected {expected}. The \ + spread template writes offsets on the assumption that rungs 0-3 are the bid \ + side and 4-7 the ask side" + ); + // A bid offset must never be above the mid and an ask offset never below it, or the + // venue would be quoting through itself. + if rung < 4 { + assert!( + tick <= 0, + "{pool} table {table} rung {rung}: bid tick {tick} > 0" + ); + } else { + assert!( + tick >= 0, + "{pool} table {table} rung {rung}: ask tick {tick} < 0" + ); + } + ask_share_total += u32_at(data, o) as u64; + } + // Offsets must widen outward, otherwise "level 4 dominates a large trade" is not true and + // the template's guidance would mislead. + for rung in [0usize, 1, 2, 4, 5, 6] { + let inner = i32_at(data, table + rung * BISONFI_RUNG + 12).abs(); + let outer = i32_at(data, table + (rung + 1) * BISONFI_RUNG + 12).abs(); + assert!( + outer >= inner, + "{pool} table {table}: rung {} offset {outer} is closer to the mid than rung \ + {rung}'s {inner}; the ladder is supposed to widen outward", + rung + 1 + ); + } + // Shares are basis points of the book, so the side cannot allocate more than all of it. + // Note the 9999 the program checks at instruction 19120 is an overflow guard on the high + // word of a share*amount product, NOT a bound on this sum: pool 7ZTpmqKW... allocates a + // full 10000, and reading the code's 9999 as a sum limit is what this assertion caught. + assert!( + ask_share_total <= 10_000, + "{pool} table {table}: ask shares sum to {ask_share_total} bps, more than the whole \ + book" + ); + } + } +} + +/// The behavioural half of the spread claim, on every pool that can quote: widening the ladder must +/// make a sell strictly worse, tightening it must make it strictly better, and writing the mirrored +/// table at 288 must change nothing at all. +/// +/// The last assertion is the one that matters most. Table 288 looks exactly like a ladder, is the +/// same size, sits at a lower offset, and is what an earlier version of this work assumed was live. +/// It is inert, so a template pointed at it would appear to write cleanly and silently do nothing. +#[tokio::test] +async fn bisonfi_spread_lever_moves_the_quote_on_every_pool() { + let elf = bisonfi_elf().await; + let all = fetch(&BISONFI_ALL_POOLS).await; + let programs = bisonfi_token_programs(&all).await; + const WIDE: i32 = -25_600; // 1% below mid + const TIGHT: i32 = -13; // about 5 ppm below mid + /// The spread the two tick values differ by. A uniform write puts every slice of the trade at the + /// same offset, so the realized gap should approach this and can never exceed it. + const EXPECTED_GAP: f64 = (TIGHT - WIDE) as f64 / 2_560_000.0; + + // The ladder engages over a window of trade size that differs per market and falls away again on + // very large trades, so the claim is per pool: SOME size pays essentially the whole configured + // spread. Asserting a single fixed size would be asserting a coincidence. + let divs: [u64; 8] = [1000, 200, 100, 50, 20, 10, 4, 2]; + let mut peaks: Vec<(&str, f64, u64)> = Vec::new(); + + for ((pool, data), tp) in BISONFI_ALL_POOLS + .iter() + .zip(all.iter()) + .zip(programs.iter()) + { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + if base_reserve < 1_000_000 { + continue; // dormant market, nothing to price + } + // Drive the SHIPPED template, not raw offsets. This matters and is not stylistic: the + // template writes the bid ticks of all four ladder regions, where the older raw-offset version + // of this test wrote only two of them. A market whose watermark gate happens to be blocking + // the 528 -> 288 refresh prices out of the working copy, so writing only the source region + // moves nothing at all. DSzgmzz1 was in exactly that state and reported 0.0000% of the + // configured spread while the template reaches it. Going through the template also means this + // per-pool proof covers what we actually ship rather than a parallel implementation of it. + let set_bids = |v: i32| bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(v)); + let mut best = (0.0f64, 0u64); + let mut quoted = false; + + for div in divs { + let size = base_reserve / div; + let baseline = match bisonfi_replay(&elf, pool, data, *tp, size, 0, |_| {}) { + Ok(out) if out > 0 => out, + _ => continue, + }; + quoted = true; + let wide = bisonfi_replay(&elf, pool, data, *tp, size, 0, set_bids(WIDE)) + .unwrap_or_else(|e| panic!("{pool} at 1/{div} of reserve: widening failed: {e}")); + let tight = bisonfi_replay(&elf, pool, data, *tp, size, 0, set_bids(TIGHT)) + .unwrap_or_else(|e| panic!("{pool} at 1/{div} of reserve: tightening failed: {e}")); + assert!( + tight >= wide, + "{pool} at 1/{div} of reserve: a 5 ppm spread paid {tight} and a 1% spread paid \ + {wide}; widening the ladder must never pay the seller more" + ); + + let gap = (tight - wide) as f64 / tight as f64; + // The hard ceiling. A uniform write puts every slice at the same offset, so the realized + // gap cannot exceed what the two tick values differ by - if it does, the unit is wrong. + assert!( + gap <= EXPECTED_GAP * 1.02, + "{pool} at 1/{div} of reserve: realized gap {:.4}% exceeds the {:.4}% the tick \ + difference allows, so the 1/2,560,000 unit the template documents is wrong", + gap * 100.0, + EXPECTED_GAP * 100.0 + ); + if gap > best.0 { + best = (gap, div); + } + + // The 288-versus-528 question this test used to hedge about is settled: 288 and 1036 are + // working copies refreshed from 528 and 1196 by a watermark-gated memcpy (traced at + // 10310-10387). The template writes all four regions for that + // reason, so there is no longer an unmeasured case to leave un-asserted here. + let _ = baseline; + } + + if quoted { + assert!( + best.0 >= EXPECTED_GAP * 0.80, + "{pool}: the best of {} trade sizes paid only {:.4}% of spread where {:.4}% was \ + configured. The lever has to reach close to what it is set to on every market that \ + quotes, or the template's unit and guidance would mislead", + divs.len(), + best.0 * 100.0, + EXPECTED_GAP * 100.0 + ); + peaks.push((pool, best.0, best.1)); + } + } + + // Without this the whole test could pass while quoting on nothing at all. Six of the seventeen v3 + // pools publish a current mid and can be replayed; two more are live but quote a Token-2022 base + // asset the harness cannot build accounts for (Custom(60)), and the rest are dormant, between 13 + // and 30 million slots behind, and return no quote whatever is written to them. + assert!( + peaks.len() >= 6, + "only {} pools produced a quote at any size: {peaks:?}. This test proves nothing if the \ + markets are not actually pricing", + peaks.len() + ); +} + +const BISONFI_PROGRAM: &str = "BiSoNHVpsVZW2F7rx2eQ59yQwKxzU5NvBcmKshCSUypi"; + +const BISONFI_PROGRAMDATA: &str = "42snJ7ip4zKKsip3EtaMoBo8wzoRsQJSzgUSFXAVJFfG"; + +const BISONFI_NINTH: &str = "8xeaWCsJYxRoudEZGJWURdfrtFhLYZz9b4iHJnW5tb3d"; + +/// The control the whole exercise needed: the deployed program, entered against a forked pool, +/// prices a swap. It returned zero for a long time because LiteSVM reports LastRestartSlot as 0 and +/// the program refuses to quote below 246_464_040 - it logs "LRS0", Last Restart Slot, and gives up. +/// +/// Asserts the fill lands just below the pool's own published mid, which is the end-to-end check +/// that `fair_value` is the price this venue actually quotes on. +#[tokio::test] +async fn bisonfi_swap_replay_prices_near_the_published_mid() { + const ONE_SOL: u64 = 1_000_000_000; + + let fork = bisonfi_fork(BISONFI_POOL).await; + let mid = u128::from_le_bytes(fork.pool[832..848].try_into().unwrap()) as f64 / 2f64.powi(88); + let out = bisonfi_run(&fork, ONE_SOL, 0, |_| {}) + .expect("the forked pool should price a one SOL sell"); + assert!(out > 0, "a live pool should quote a non-zero amount"); + + // USDC has six decimals, so `out` is the quote in micro-units for one whole SOL. + let realized = out as f64 / 1e6; + let shortfall_ppm = (mid - realized) / mid * 1e6; + assert!( + (0.0..2_000.0).contains(&shortfall_ppm), + "a one SOL sell should fill just below the published mid of {mid}, got {realized} \ + ({shortfall_ppm:.1} ppm away)" + ); +} + +/// Harness control. Proves the replay rig propagates a signer and has the token program loaded, +/// so a MissingRequiredSignature from BisonFi means something about BisonFi. +#[tokio::test] +async fn bisonfi_replay_rig_propagates_signers() { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let token_program = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + let usdc = Pubkey::from_str_const(USDC_MINT); + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000).unwrap(); + let (a, b) = (Pubkey::new_unique(), Pubkey::new_unique()); + let mk = |amount| Account { + lamports: 10_000_000_000, + data: token_account(&usdc, &taker.pubkey(), amount), + owner: token_program, + executable: false, + rent_epoch: 0, + }; + svm.set_account(a, mk(1_000_000)).unwrap(); + svm.set_account(b, mk(0)).unwrap(); + + // SPL Token Transfer: tag 3, u64 amount. Authority must be a signer. + let mut data = vec![3u8]; + data.extend_from_slice(&500_000u64.to_le_bytes()); + let ix = Instruction { + program_id: token_program, + accounts: vec![ + AccountMeta::new(a, false), + AccountMeta::new(b, false), + AccountMeta::new_readonly(taker.pubkey(), true), + ], + data, + }; + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&taker.pubkey()), + &[&taker], + svm.latest_blockhash(), + ); + let res = svm.send_transaction(tx); + assert!( + res.is_ok(), + "the rig cannot even authorise an SPL transfer, so it cannot test BisonFi: {:?}", + res.err().map(|e| (e.err, e.meta.logs)) + ); + assert_eq!(spl_amount(&svm.get_account(&b).unwrap().data), 500_000); +} + +/// The guard must refuse the one pool that is the right size and carries the right magic but is a +/// different layout version. Without the version in the guard this write would land at offset 832 +/// of a v2 account and corrupt whatever lives there. +#[tokio::test] +async fn bisonfi_guard_refuses_the_v2_pool() { + let data = fetch(&[BISONFI_V2_POOL]).await.remove(0); + assert_eq!( + data.len(), + 2048, + "the v2 pool is the same size as a v3 pool" + ); + assert_eq!(&data[..8], b"POOLSTAT", "and carries the same magic"); + assert_eq!( + u64::from_le_bytes(data[8..16].try_into().unwrap()), + 2, + "this test only means anything while that pool is still version 2" + ); + + // Every BisonFi template, taken from the registry rather than a hand-written list, so a template + // added later cannot quietly escape the guard check. + let registry = TemplateRegistry::new(); + let ids: Vec = registry + .all() + .iter() + .filter(|t| t.id.starts_with("bisonfi-")) + .map(|t| t.id.clone()) + .collect(); + assert!( + ids.len() >= 4, + "expected every BisonFi template, found {ids:?}" + ); + for id in ids { + let template = registry.get(&id).unwrap(); + let raw_layout = template.raw_layout.as_ref().unwrap(); + assert!( + raw_layout.guard(&data).is_err(), + "{id} must refuse a v2 pool: size and magic match, but the layout does not" + ); + } +} + +/// And it must still accept every v3 pool, so the tightened guard has not over-fitted. +#[tokio::test] +async fn bisonfi_guard_accepts_every_v3_pool() { + let all = fetch(&BISONFI_ALL_POOLS).await; + let registry = TemplateRegistry::new(); + let templates: Vec<_> = registry + .all() + .into_iter() + .filter(|t| t.id.starts_with("bisonfi-")) + .collect(); + assert!( + templates.len() >= 4, + "expected every BisonFi template, found {}", + templates.len() + ); + + for (pool, data) in BISONFI_ALL_POOLS.iter().zip(all.iter()) { + assert_eq!( + u64::from_le_bytes(data[8..16].try_into().unwrap()), + 3, + "{pool} is expected to be a version 3 pool" + ); + for template in &templates { + let raw_layout = template.raw_layout.as_ref().unwrap(); + raw_layout + .guard(data) + .unwrap_or_else(|e| panic!("{}: guard rejected v3 pool {pool}: {e}", template.id)); + } + } +} + +/// Replays a swap against arbitrary pool bytes with no RPC of its own, synthesizing the vaults from +/// the reserves they were measured to mirror. +/// +/// This exists so a behavioural claim can be made about *every* pool rather than the one the +/// templates point at. Seventeen pools times several mutations times both directions is several +/// hundred swaps: fine in LiteSVM, and impossible against a live endpoint. The compute limit is +/// raised because the 200k default cannot finish a full rung walk, which is what made the ladder +/// look inert the first time it was tested. +fn bisonfi_replay( + elf: &[u8], + pool_addr: &str, + pool_bytes: &[u8], + token_programs: (Pubkey, Pubkey), + amount_in: u64, + direction: u8, + mutate: impl FnOnce(&mut Vec), +) -> Result { + bisonfi_replay_min_out( + elf, + pool_addr, + pool_bytes, + token_programs, + amount_in, + 0, + direction, + mutate, + ) +} + +/// As [`bisonfi_replay`] but sets the swap's second u64, which the instruction layout suggests is a +/// minimum-output bound. Every other caller passes zero, so this is the only place its behaviour is +/// exercised - and whether it is enforced decides what a silenced venue looks like to a real +/// integration: a transaction that quietly moves nothing, or one that reverts. +#[allow(clippy::too_many_arguments)] +fn bisonfi_replay_min_out( + elf: &[u8], + pool_addr: &str, + pool_bytes: &[u8], + token_programs: (Pubkey, Pubkey), + amount_in: u64, + min_out: u64, + direction: u8, + mutate: impl FnOnce(&mut Vec), +) -> Result { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let mut pool = pool_bytes.to_vec(); + if pool.len() != 2048 { + return Err(format!("pool is {} bytes, expected 2048", pool.len())); + } + let g64 = |b: &[u8], o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap()); + let base_reserve = g64(&pool, 48); + let quote_reserve = g64(&pool, 56); + let base_vault = Pubkey::new_from_array(pool[120..152].try_into().unwrap()); + let quote_vault = Pubkey::new_from_array(pool[152..184].try_into().unwrap()); + let base_mint = Pubkey::new_from_array(pool[184..216].try_into().unwrap()); + let quote_mint = Pubkey::new_from_array(pool[216..248].try_into().unwrap()); + let pool_slot = g64(&pool, 72); + mutate(&mut pool); + + let program_id = Pubkey::from_str_const(BISONFI_PROGRAM); + let pool_key = pool_addr + .parse::() + .map_err(|_| format!("bad pool address {pool_addr}"))?; + // One program per side: slots 6 and 7 of the instruction are the base and quote token programs, + // which is why the account list appears to name the token program twice. + let (base_program, quote_program) = token_programs; + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, elf) + .map_err(|e| format!("add_program: {e:?}"))?; + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = pool_slot; + clock.unix_timestamp = 1_787_041_969; + svm.set_sysvar(&clock); + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|e| format!("set last_restart_slot: {e:?}"))?; + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(pool_key, owned(pool, program_id)) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + base_vault, + owned( + token_account(&base_mint, &pool_key, base_reserve), + base_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + quote_vault, + owned( + token_account("e_mint, &pool_key, quote_reserve + 79_168), + quote_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("{e:?}"))?; + let (src_ta, dst_ta) = (Pubkey::new_unique(), Pubkey::new_unique()); + let (base_amt, quote_amt) = if direction == 0 { + (amount_in.saturating_mul(10), 0) + } else { + (0, amount_in.saturating_mul(10)) + }; + svm.set_account( + src_ta, + owned( + token_account(&base_mint, &taker.pubkey(), base_amt), + base_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + dst_ta, + owned( + token_account("e_mint, &taker.pubkey(), quote_amt), + quote_program, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let mut data = Vec::with_capacity(19); + data.push(0x07); + data.extend_from_slice(&amount_in.to_le_bytes()); + data.extend_from_slice(&min_out.to_le_bytes()); + data.push(direction); + data.push(0); + + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_400_000u32.to_le_bytes()); + let ixs = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(pool_key, false), + AccountMeta::new(base_vault, false), + AccountMeta::new(quote_vault, false), + AccountMeta::new(src_ta, false), + AccountMeta::new(dst_ta, false), + AccountMeta::new_readonly(base_program, false), + AccountMeta::new_readonly(quote_program, false), + AccountMeta::new_readonly(Pubkey::from_str_const(BISONFI_NINTH), true), + ], + data, + }, + ]; + let mut msg = solana_message::Message::new(&ixs, Some(&taker.pubkey())); + msg.recent_blockhash = svm.latest_blockhash(); + let nsig = msg.header.num_required_signatures as usize; + let mut tx = Transaction::new_unsigned(msg); + tx.signatures = vec![solana_signature::Signature::default(); nsig]; + let sig = taker.sign_message(&tx.message.serialize()); + tx.signatures[0] = sig; + + match svm.send_transaction(tx) { + Ok(_) => { + let out = if direction == 0 { + spl_amount(&svm.get_account(&dst_ta).unwrap().data) + } else { + spl_amount(&svm.get_account(&src_ta).unwrap().data) + }; + Ok(out) + } + Err(e) => Err(format!("{:?}", e.err)), + } +} + +/// The program ELF, fetched once per machine and reused. Delete the file to pick up a redeploy. +async fn bisonfi_elf() -> Vec { + let cache = std::env::temp_dir().join("surfpool-bisonfi-program.so"); + match std::fs::read(&cache) { + Ok(bytes) if bytes.len() > 200_000 => bytes, + _ => { + let bytes = fetch(&[BISONFI_PROGRAMDATA]).await.remove(0)[45..].to_vec(); + let _ = std::fs::write(&cache, &bytes); + bytes + } + } +} + +/// Offsets of the live quote ladder. `LADDER` is the table the program actually prices from; +/// `LADDER_INERT` is the mirrored table that writing has no effect on, kept here so the test that +/// proves the difference cannot drift away from the template. +const BISONFI_LADDER: usize = 528; + +const BISONFI_LADDER_MIRROR: usize = 288; + +/// A rung is 16 bytes: share-if-ask, share-if-bid, level, tick offset. +const BISONFI_RUNG: usize = 16; + +/// A forked pool plus the deployed program, ready to run swaps against. +#[derive(Clone)] +struct BisonfiFork { + elf: Vec, + pool_addr: Pubkey, + pool: Vec, + base_vault: (Pubkey, Vec, u64), + quote_vault: (Pubkey, Vec, u64), +} + +/// Cached per process, keyed by pool. Several tests fork the same market, and refetching it for each +/// one is what exhausts the public endpoint. One snapshot per suite run is also more consistent: +/// tests then compare against identical state rather than a market that moved between them. +fn bisonfi_fork_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +/// Two batched reads: the program and pool, then the vaults the pool names. +async fn bisonfi_fork(pool_addr: &str) -> BisonfiFork { + if let Some(hit) = bisonfi_fork_cache() + .lock() + .ok() + .and_then(|c| c.get(pool_addr).cloned()) + { + return hit; + } + let fork = bisonfi_fork_uncached(pool_addr).await; + if let Ok(mut c) = bisonfi_fork_cache().lock() { + c.insert(pool_addr.to_string(), fork.clone()); + } + fork +} + +async fn bisonfi_fork_uncached(pool_addr: &str) -> BisonfiFork { + // The ELF is ~250 KB and the same for every pool, so it is fetched once per machine and cached. + // Delete the file to pick up a redeploy. + let cache = std::env::temp_dir().join("surfpool-bisonfi-program.so"); + let elf = match std::fs::read(&cache) { + Ok(bytes) if bytes.len() > 200_000 => bytes, + _ => { + let bytes = fetch(&[BISONFI_PROGRAMDATA]).await.remove(0)[45..].to_vec(); + let _ = std::fs::write(&cache, &bytes); + bytes + } + }; + // The vault addresses live in the pool, so learning them takes one read - but the pool's cached + // reserves and the vault balances must come from the SAME slot or they disagree. This market + // turns over tens of thousands of dollars between two requests, which is enough to make the pool + // look like it claims more than it holds. So the first read is only used for the addresses and + // everything is then re-read together. + let probe = fetch(&[pool_addr]).await.remove(0); + assert_eq!(probe.len(), 2048, "{pool_addr} should be a 2048-byte pool"); + let bv = Pubkey::new_from_array(probe[120..152].try_into().unwrap()); + let qv = Pubkey::new_from_array(probe[152..184].try_into().unwrap()); + let snap = fetch_with_lamports(&[pool_addr, &bv.to_string(), &qv.to_string()]).await; + BisonfiFork { + elf, + pool_addr: Pubkey::from_str_const(pool_addr), + pool: snap[0].0.clone(), + base_vault: (bv, snap[1].0.clone(), snap[1].1), + quote_vault: (qv, snap[2].0.clone(), snap[2].1), + } +} + +/// Runs one swap against a mutated copy of the fork. `direction` 0 sells the base token, 1 buys it. +fn bisonfi_run( + fork: &BisonfiFork, + amount_in: u64, + direction: u8, + mutate: impl FnOnce(&mut Vec), +) -> Result { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let mut pool = fork.pool.clone(); + let pool_slot = u64::from_le_bytes(pool[72..80].try_into().unwrap()); + let base_mint = Pubkey::new_from_array(pool[184..216].try_into().unwrap()); + let quote_mint = Pubkey::new_from_array(pool[216..248].try_into().unwrap()); + mutate(&mut pool); + + let program_id = Pubkey::from_str_const(BISONFI_PROGRAM); + let token_program = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, &fork.elf) + .map_err(|e| format!("add_program: {e:?}"))?; + + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = pool_slot; + clock.unix_timestamp = 1_787_041_969; + svm.set_sysvar(&clock); + // The program refuses to quote unless LastRestartSlot is at least this, logging "LRS0". + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|e| format!("{e:?}"))?; + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(fork.pool_addr, owned(pool, program_id)) + .map_err(|e| format!("{e:?}"))?; + let vault_acct = |data: Vec, lamports: u64| Account { + lamports, + data, + owner: token_program, + executable: false, + rent_epoch: 0, + }; + svm.set_account( + fork.base_vault.0, + vault_acct(fork.base_vault.1.clone(), fork.base_vault.2), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + fork.quote_vault.0, + vault_acct(fork.quote_vault.1.clone(), fork.quote_vault.2), + ) + .map_err(|e| format!("{e:?}"))?; + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("{e:?}"))?; + // Slots 4 and 5 are the user's base and quote accounts, fixed by mint; direction decides flow. + let (user_base, user_quote) = (Pubkey::new_unique(), Pubkey::new_unique()); + let (base_amt, quote_amt) = if direction == 0 { + (amount_in.saturating_mul(2), 0) + } else { + (0, amount_in.saturating_mul(2)) + }; + // A wrapped-SOL account's lamports must cover its balance plus rent, or paying out the base + // token leaves the instruction unbalanced. + const TOKEN_RENT: u64 = 2_039_280; + svm.set_account( + user_base, + vault_acct( + token_account(&base_mint, &taker.pubkey(), base_amt), + base_amt.saturating_add(TOKEN_RENT), + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + user_quote, + vault_acct( + token_account("e_mint, &taker.pubkey(), quote_amt), + TOKEN_RENT, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let mut data = Vec::with_capacity(19); + data.push(0x07); + data.extend_from_slice(&amount_in.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.push(direction); + data.push(0); + + let ix = Instruction { + program_id, + accounts: vec![ + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(fork.pool_addr, false), + AccountMeta::new(fork.base_vault.0, false), + AccountMeta::new(fork.quote_vault.0, false), + AccountMeta::new(user_base, false), + AccountMeta::new(user_quote, false), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(Pubkey::from_str_const(BISONFI_NINTH), true), + ], + data, + }; + // Walking several rungs costs well over the 200k default; the routed swaps observed on mainnet + // run with ~600k available. SetComputeUnitLimit is discriminant 2 followed by a u32. + let mut cu_data = vec![2u8]; + cu_data.extend_from_slice(&1_400_000u32.to_le_bytes()); + let cu_ix = Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: cu_data, + }; + + let mut msg = solana_message::Message::new(&[cu_ix, ix], Some(&taker.pubkey())); + msg.recent_blockhash = svm.latest_blockhash(); + let nsig = msg.header.num_required_signatures as usize; + let mut tx = Transaction::new_unsigned(msg); + tx.signatures = vec![solana_signature::Signature::default(); nsig]; + tx.signatures[0] = taker.sign_message(&tx.message.serialize()); + + match svm.send_transaction(tx) { + Ok(_) => Ok(spl_amount( + &svm.get_account(if direction == 0 { + &user_quote + } else { + &user_base + }) + .unwrap() + .data, + )), + Err(e) => Err(format!("{:?}", e.err)), + } +} + +/// `fair_value` is claimed to be the price the venue quotes on. This pins the exact relationship: +/// scaling it must scale the quote by the same factor, against the deployed program. +#[tokio::test] +async fn bisonfi_fair_value_scales_the_quote_exactly() { + const ONE_SOL: u64 = 1_000_000_000; + let fork = bisonfi_fork(BISONFI_POOL).await; + let mid = u128::from_le_bytes(fork.pool[832..848].try_into().unwrap()); + + let base = bisonfi_run(&fork, ONE_SOL, 0, |_| {}).expect("control should price"); + let doubled = bisonfi_run(&fork, ONE_SOL, 0, |d| { + d[832..848].copy_from_slice(&(mid * 2).to_le_bytes()) + }) + .expect("doubled mid should price"); + let halved = bisonfi_run(&fork, ONE_SOL, 0, |d| { + d[832..848].copy_from_slice(&(mid / 2).to_le_bytes()) + }) + .expect("halved mid should price"); + + // Integer maths, so allow a unit of rounding either way rather than demanding bit equality. + assert!( + doubled.abs_diff(base * 2) <= 2, + "doubling fair_value should double the quote: {base} -> {doubled}" + ); + assert!( + halved.abs_diff(base / 2) <= 2, + "halving fair_value should halve the quote: {base} -> {halved}" + ); +} + +/// The depth template's claim, on several markets with different reserve ratios rather than one. +/// Lowering the reserve the pool pays out of must make the same trade fill worse. +#[tokio::test] +async fn bisonfi_depth_lever_is_monotonic_on_every_quoting_market() { + let rig = bisonfi_rig().await; + let mut checked = 0usize; + + for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + let quote_reserve = u64::from_le_bytes(data[56..64].try_into().unwrap()); + let name = String::from_utf8_lossy( + &data[256..288] + .iter() + .copied() + .take_while(|b| *b != 0) + .collect::>(), + ) + .to_string(); + + let at = |scaled: u64, size: u64| { + rig.try_scenario( + pool, + data, + *tp, + "bisonfi-depth", + &[("quote_reserve", serde_json::json!(scaled))], + size, + 0, + ) + }; + + // Assert on every size where all three legs price, rather than one hand-picked size. The + // ladder engages over a window that differs per market, so a fixed size would be asserting a + // coincidence about today's state - but wherever the market CAN price all three, the ordering + // is a claim the template makes and must hold. + let mut ordered_points = 0usize; + let mut strict_points = 0usize; + for div in [200u64, 100, 50, 20, 10, 5] { + let size = base_reserve / div; + if size == 0 { + continue; + } + let deep = at(quote_reserve.saturating_mul(10), size); + let control = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}); + let thin = at(quote_reserve / 2, size); + let (deep, control, thin) = match (deep, control, thin) { + (Ok(d), Ok(c), Ok(t)) if d > 0 && c > 0 && t > 0 => (d, c, t), + _ => continue, // this market cannot price all three at this size + }; + assert!( + deep >= control && control >= thin, + "{name} at 1/{div} of base reserve: a deeper quote reserve must never pay out less \ + and a thinner one never more, got deep={deep} control={control} thin={thin}" + ); + ordered_points += 1; + if deep > control && control > thin { + strict_points += 1; + } + } + + assert!( + ordered_points > 0, + "{name}: no trade size priced under all three depths, so the depth lever was never \ + actually exercised on this market" + ); + // Monotonic everywhere measurable is necessary but not sufficient - a lever that did nothing + // would satisfy it with equalities. At least one size has to respond strictly. + assert!( + strict_points > 0, + "{name}: the ordering held at {ordered_points} sizes but never strictly, so a 20x range \ + of quote reserve changed nothing. The depth template would not be a lever at all" + ); + checked += 1; + } + + // Previously this test covered three hardcoded markets while the other three templates were proven + // on every pool that quotes. Without this floor it could silently narrow back to one. + assert!( + checked >= 6, + "only {checked} quoting markets exercised the depth lever" + ); +} + +/// Buying pays out of the base reserve, so that is the side that constrains a buy. Confirms the +/// template's direction guidance is the right way round. +#[tokio::test] +async fn bisonfi_depth_lever_is_direction_specific() { + const SELL: u64 = 10_000_000_000_000; // 10k SOL + const BUY: u64 = 500_000_000_000; // 500k USDC + let fork = bisonfi_fork(BISONFI_POOL).await; + let scale = |off: usize, num: u64| { + move |d: &mut Vec| { + let v = u64::from_le_bytes(d[off..off + 8].try_into().unwrap()); + d[off..off + 8].copy_from_slice(&v.saturating_mul(num).to_le_bytes()) + } + }; + + let sell_base = bisonfi_run(&fork, SELL, 0, |_| {}).expect("control sell"); + assert_eq!( + bisonfi_run(&fork, SELL, 0, scale(48, 10)).expect("sell with deeper base"), + sell_base, + "the base reserve must not affect a sell, which pays out quote" + ); + assert!( + bisonfi_run(&fork, SELL, 0, scale(56, 10)).expect("sell with deeper quote") > sell_base, + "the quote reserve must affect a sell" + ); + + let buy_base = bisonfi_run(&fork, BUY, 1, |_| {}).expect("control buy"); + assert!( + bisonfi_run(&fork, BUY, 1, scale(48, 10)).expect("buy with deeper base") > buy_base, + "the base reserve must affect a buy, which pays out base" + ); +} + +/// The template warns that raising a reserve above the vault's real balance breaks settlement. That +/// warning is only worth printing if it is true. +#[tokio::test] +async fn bisonfi_raising_a_reserve_past_the_vault_fails_to_settle() { + let fork = bisonfi_fork(BISONFI_POOL).await; + let held = spl_amount(&fork.quote_vault.1); + let cached = u64::from_le_bytes(fork.pool[56..64].try_into().unwrap()); + // Same-slot snapshot, so the pool's cached quote must not exceed what the vault actually holds. + assert!( + cached <= held, + "same-slot pool and vault disagree: pool claims {cached} quote, vault holds {held}" + ); + + // Claim a thousand times the quote the vault actually has, then try to draw more than it holds. + let sell = 10_000_000_000_000u64; // 10k SOL, worth far more than the vault at 1000x depth + let res = bisonfi_run(&fork, sell, 0, move |d| { + d[56..64].copy_from_slice(&cached.saturating_mul(1000).to_le_bytes()) + }); + match res { + Err(e) => assert!( + !e.is_empty(), + "raising the reserve past the vault should fail, and it did: {e}" + ), + Ok(out) => assert!( + out <= held, + "if it settles at all it can only pay out what the vault holds ({held}), paid {out}" + ), + } +} + +/// The freshness template tells callers to age the quote by N slots. This finds the N at which the +/// venue actually stops quoting, so the guidance can state a real number instead of guessing. +#[tokio::test] +async fn bisonfi_staleness_threshold_is_known() { + const ONE_SOL: u64 = 1_000_000_000; + let fork = bisonfi_fork(BISONFI_POOL).await; + let last = u64::from_le_bytes(fork.pool[72..80].try_into().unwrap()); + let age_by = + |n: u64| move |d: &mut Vec| d[72..80].copy_from_slice(&(last - n).to_le_bytes()); + + assert!(bisonfi_run(&fork, ONE_SOL, 0, age_by(0)).expect("fresh") > 0); + + // Smallest age that stops the quote, by binary search over a generous range. + let (mut lo, mut hi) = (0u64, 4096u64); + assert_eq!( + bisonfi_run(&fork, ONE_SOL, 0, age_by(hi)).unwrap_or(0), + 0, + "aging by {hi} slots should stop the venue quoting" + ); + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if bisonfi_run(&fork, ONE_SOL, 0, age_by(mid)).unwrap_or(0) > 0 { + lo = mid; + } else { + hi = mid; + } + } + println!(" staleness cliff: quotes at -{lo} slots, refuses at -{hi}"); + assert!( + (1..=4096).contains(&hi), + "expected a cliff inside the searched range, found {hi}" + ); + // Pin it so a redeploy that changes the tolerance is noticed. + assert!( + (2..=2000).contains(&hi), + "the staleness tolerance moved to {hi} slots; update the freshness template guidance" + ); +} + +/// SCENARIO: the maker widens its quote ladder between the caller pricing and the caller filling. +/// +/// The spread counterpart to `bisonfi_scenario_mid_moves_between_quote_and_fill`, and the last of the +/// four templates to get a proof that it works as a scheduled, across-slots override rather than a +/// single write. It is also the most realistic way a PMM degrades: a maker that has stopped liking the +/// flow widens before it goes dark, so a taker sees a fill that is legal, non-zero, and worse than the +/// number it priced on. +/// +/// The trade size is searched rather than fixed. The ladder only engages over a window of size that +/// differs per market, so a hardcoded size would be asserting a coincidence about today's live state. +#[tokio::test] +async fn bisonfi_scenario_spread_widens_between_quote_and_fill() { + use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + + const BASE_SLOT: u64 = 2_000_000; + const FILL_AT: u64 = 2; + const TIGHT: i32 = -13; // about 5 ppm below mid + const WIDE: i32 = -25_600; // 1% below mid + /// Widening from TIGHT to WIDE cannot cost the seller more than the tick difference. + const MAX_GAP: f64 = (TIGHT - WIDE) as f64 / 2_560_000.0; + + let rig = bisonfi_rig().await; + + // Find a market and a size where the ladder is genuinely engaged, so that widening it has to show + // up in the fill. Without this the test could pass on a size where the spread is simply inert. + let mut chosen: Option<(&str, &Vec, (Pubkey, Pubkey), u64, u64, u64)> = None; + 'search: for (pool, data, tp) in &rig.quoting { + let base_reserve = u64::from_le_bytes(data[48..56].try_into().unwrap()); + for div in [1000u64, 200, 100, 50, 20, 10, 4, 2] { + let size = base_reserve / div; + if size == 0 { + continue; + } + let tight = match bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(TIGHT)) + }) { + Ok(o) if o > 0 => o, + _ => continue, + }; + let wide = match bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(WIDE)) + }) { + Ok(o) if o > 0 => o, + _ => continue, + }; + // Require most of the configured spread to be reachable at this size. + if (tight - wide) as f64 / tight as f64 >= MAX_GAP * 0.5 { + chosen = Some((pool, data, *tp, size, tight, wide)); + break 'search; + } + } + } + let (pool, data, tp, size, _, _) = chosen.expect( + "no quoting market engaged its ladder at any of the eight sizes tried, so a mid-flight \ + widening cannot be demonstrated. Investigate before relaxing this", + ); + let pool_key = pool.parse::().expect("pool address"); + + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account( + pool_key, + solana_account::Account { + lamports: 1_000_000, + data: data.clone(), + owner: Pubkey::from_str_const(BISONFI_PROGRAM), + executable: false, + rent_epoch: 0, + }, + ) + .expect("seed the pool account"); + + let mut scenario = Scenario::new( + "BisonFi widens mid-flight".to_string(), + "Quotes a tight ladder at the slot the caller prices on and a 1% ladder before the \ + transaction executes" + .to_string(), + ); + for (relative, tick) in [(0u64, TIGHT), (FILL_AT, WIDE)] { + scenario.add_override( + OverrideInstance::new( + "bisonfi-spread".to_string(), + relative, + AccountAddress::Pubkey(pool_key.to_string()), + ) + .with_values( + bisonfi_spread_bids(tick) + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect::>(), + ), + ); + } + svm.register_scenario(scenario, Some(BASE_SLOT)) + .expect("register scenario"); + + let mut images: HashMap> = HashMap::new(); + for slot in BASE_SLOT..=BASE_SLOT + FILL_AT { + svm.materialize_overrides_for_slot(&None, slot) + .await + .expect("materialize"); + images.insert( + slot, + svm.inner + .get_account(&pool_key) + .expect("get_account") + .expect("account present") + .data, + ); + } + + // The scheduled writes landed in the right slots, in every region the template covers. + for (path, offset, count) in BISONFI_SPREAD_PROPS { + if !path.contains(".0.") { + continue; // only the bid half is scheduled here + } + for rung in 0..count { + let at = offset + rung * BISONFI_RUNG; + let read = + |slot: u64| i32::from_le_bytes(images[&slot][at..at + 4].try_into().unwrap()); + assert_eq!( + read(BASE_SLOT), + TIGHT, + "{path} rung {rung} at {at}: the quoting slot must carry the tight ladder" + ); + assert_eq!( + read(BASE_SLOT + FILL_AT), + WIDE, + "{path} rung {rung} at {at}: the widening step must have fired by the fill slot" + ); + } + } + + // What the caller quoted, and therefore the minimum they would sign for. + let quoted = bisonfi_replay(&rig.elf, pool, data, tp, size, 0, { + let image = images[&BASE_SLOT].clone(); + move |d: &mut Vec| *d = image + }) + .expect("the quoting slot must fill"); + assert!(quoted > 0, "the caller's quote has to be real"); + + let image = images[&(BASE_SLOT + FILL_AT)].clone(); + let unprotected = bisonfi_replay(&rig.elf, pool, data, tp, size, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }) + .expect("a widened market still quotes, just worse"); + assert!( + unprotected < quoted, + "widening the ladder from 5 ppm to 1% must pay the seller less: got {unprotected} against \ + a quote of {quoted}" + ); + let realized = (quoted - unprotected) as f64 / quoted as f64; + assert!( + realized <= MAX_GAP * 1.02, + "the fill lost {:.4}% but the tick difference only allows {:.4}%, so the 1/2,560,000 unit \ + the template documents is wrong", + realized * 100.0, + MAX_GAP * 100.0 + ); + + // And the case a consumer can actually detect: signing for the quoted price reverts. + let protected = bisonfi_replay_min_out(&rig.elf, pool, data, tp, size, quoted, 0, { + let image = image.clone(); + move |d: &mut Vec| *d = image + }); + assert!( + protected.is_err(), + "signing for the price that was quoted must REVERT once the maker has widened, got \ + {protected:?}" + ); +} + +const WHIRLPOOL_PROGRAM: &str = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"; + +/// Every account an Orca Whirlpool `swap` needs is derivable and present, so the AMM leg of the +/// cross-venue arbitrage scenario can be executed rather than only priced. +/// +/// `bisonfi_scenario_arbitrage_against_an_amm` currently compares BisonFi's quote against Whirlpool's +/// published state. Making that leg atomic - both swaps in one transaction - needs three tick arrays +/// and an oracle at PDAs that are only created lazily, so whether they exist is a fact about the +/// market and not something to assume. This pins it, and pins the derivation itself: a `TickArray` +/// stores its own `start_tick_index` and a back-pointer to its whirlpool, so if the seed scheme were +/// wrong the addresses would either not resolve or resolve to another pool's arrays. An earlier +/// hand-rolled derivation that skipped the off-curve bump search produced three addresses that all +/// looked plausible and none of which existed, which is exactly the failure this guards against. +#[tokio::test] +async fn whirlpool_swap_account_graph_is_derivable_and_present() { + const TICK_ARRAY_LEN: usize = 9988; + const TICKS_PER_ARRAY: i32 = 88; + + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let wp_key = Pubkey::from_str_const(WHIRLPOOL_SOL_USDC); + let wp = fetch(&[WHIRLPOOL_SOL_USDC]).await.remove(0); + + let spacing = u16::from_le_bytes(wp[41..43].try_into().unwrap()); + let tick_current = i32::from_le_bytes(wp[81..85].try_into().unwrap()); + let mint_a = Pubkey::try_from(&wp[101..133]).expect("token_mint_a"); + let vault_a = Pubkey::try_from(&wp[133..165]).expect("token_vault_a"); + let mint_b = Pubkey::try_from(&wp[181..213]).expect("token_mint_b"); + let vault_b = Pubkey::try_from(&wp[213..245]).expect("token_vault_b"); + assert!(spacing > 0, "tick_spacing must be positive, got {spacing}"); + + // The array a tick falls in starts at a multiple of spacing*88, rounded toward negative infinity. + // Integer division truncates toward zero, which is the wrong way for the negative ticks a SOL/USDC + // pool actually sits at, so this rounds explicitly. + let per_array = spacing as i32 * TICKS_PER_ARRAY; + let start = (tick_current as f32 / per_array as f32).floor() as i32 * per_array; + assert!( + start <= tick_current && tick_current < start + per_array, + "the current tick {tick_current} must fall inside its own array [{start}, {})", + start + per_array + ); + + let (oracle, _) = Pubkey::find_program_address(&[b"oracle", wp_key.as_ref()], &prog); + let starts: Vec = [-1i32, 0, 1] + .iter() + .map(|k| start + k * per_array) + .collect(); + let arrays: Vec = starts + .iter() + .map(|s| { + Pubkey::find_program_address( + &[b"tick_array", wp_key.as_ref(), s.to_string().as_bytes()], + &prog, + ) + .0 + }) + .collect(); + + let mut addrs: Vec = arrays.iter().map(|a| a.to_string()).collect(); + addrs.push(vault_a.to_string()); + addrs.push(vault_b.to_string()); + addrs.push(oracle.to_string()); + let refs: Vec<&str> = addrs.iter().map(|s| s.as_str()).collect(); + let got = fetch_optional(&refs).await; + + for ((s, addr), data) in starts.iter().zip(arrays.iter()).zip(got.iter()) { + let data = data.as_ref().unwrap_or_else(|| { + panic!( + "tick array for start {s} ({addr}) does not exist. A swap crossing into it would \ + fail, so the atomic leg needs a pool whose neighbouring arrays are initialized" + ) + }); + assert_eq!(data.len(), TICK_ARRAY_LEN, "{addr}: not a TickArray"); + // start_tick_index sits right after the 8-byte Anchor discriminator. + assert_eq!( + i32::from_le_bytes(data[8..12].try_into().unwrap()), + *s, + "{addr}: the account's own start_tick_index disagrees with the seed it was derived \ + from, so the derivation is wrong" + ); + // ...and the trailing whirlpool back-pointer proves it belongs to THIS pool. + assert_eq!( + Pubkey::try_from(&data[TICK_ARRAY_LEN - 32..]).expect("whirlpool back-pointer"), + wp_key, + "{addr}: belongs to a different whirlpool" + ); + } + + for (label, mint, vault, data) in [ + ("a", mint_a, vault_a, &got[3]), + ("b", mint_b, vault_b, &got[4]), + ] { + let data = data + .as_ref() + .unwrap_or_else(|| panic!("token_vault_{label} {vault} does not exist")); + assert_eq!( + data.len(), + 165, + "token_vault_{label}: not an SPL token account" + ); + assert_eq!( + Pubkey::try_from(&data[0..32]).expect("vault mint"), + mint, + "token_vault_{label} does not hold the mint the whirlpool declares" + ); + } + + // The oracle is only initialized for adaptive-fee pools. Classic `swap` takes it as an + // UncheckedAccount, so an absent one is passable as an empty account - but the address still has to + // be the right PDA, which is why it is derived here rather than faked. + assert!( + got[5].is_none() || got[5].as_ref().map(|d| !d.is_empty()).unwrap_or(false), + "oracle {oracle} resolved to a zero-length account, which is neither absent nor valid" + ); +} + +/// Orca's `swap`, transcribed from the IDL the program itself publishes on chain. +/// +/// Taken from the Anchor IDL account at `2KFqE4RWoPVbvodo8vbggCFeHPS8TDvgpwp79ALMrcyn`, which carries +/// whirlpool v0.9.0, spec 0.1.0, and a self-declared address matching the program. To re-derive it: +/// the address is `create_with_seed(find_program_address([], program).0, "anchor:idl", program)`, and +/// the account holds zlib-compressed JSON behind a 44-byte header (8 discriminator, 32 authority, +/// 4 length). No copy is kept in the repo - it is 105 KB, nothing reads it, and a stale copy would +/// be worse than none if Orca redeploys. +/// +/// Transcribed rather than parsed at runtime because the IDL account stores zlib-compressed JSON and +/// this crate has no direct zlib dependency. The transcription is not load-bearing on trust: a wrong +/// account order or argument encoding cannot produce a swap that succeeds AND moves four balances +/// consistently, which is what the test below asserts. +mod whirlpool_swap { + /// `sha256("global:swap")[..8]`, and byte-identical to the IDL's declared discriminator. + pub const DISCRIMINATOR: [u8; 8] = [248, 198, 158, 145, 225, 117, 135, 200]; + /// Lower bound on sqrt price; passing it as the limit for an a-to-b swap imposes no constraint. + pub const MIN_SQRT_PRICE: u128 = 4295048016; + /// Upper bound, for the b-to-a direction. + pub const MAX_SQRT_PRICE: u128 = 79226673515401279992447579055; + + /// `amount, other_amount_threshold, sqrt_price_limit, amount_specified_is_input, a_to_b` + pub fn data(amount: u64, threshold: u64, limit: u128, is_input: bool, a_to_b: bool) -> Vec { + let mut d = DISCRIMINATOR.to_vec(); + d.extend_from_slice(&amount.to_le_bytes()); + d.extend_from_slice(&threshold.to_le_bytes()); + d.extend_from_slice(&limit.to_le_bytes()); + d.push(is_input as u8); + d.push(a_to_b as u8); + debug_assert_eq!(d.len(), 42); + d + } +} + +/// The Whirlpool program's executable, cached in the temp dir like [`bisonfi_elf`]. +async fn whirlpool_elf() -> Vec { + let cache = std::env::temp_dir().join("surfpool-whirlpool-program.so"); + if let Ok(bytes) = std::fs::read(&cache) { + if bytes.len() > 200_000 { + return bytes; + } + } + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let loader = Pubkey::from_str_const("BPFLoaderUpgradeab1e11111111111111111111111"); + let (programdata, _) = Pubkey::find_program_address(&[prog.as_ref()], &loader); + // 45 bytes of UpgradeableLoaderState::ProgramData precede the ELF. + let bytes = fetch(&[&programdata.to_string()]).await.remove(0)[45..].to_vec(); + let _ = std::fs::write(&cache, &bytes); + bytes +} + +/// Everything needed to replay a swap against one Whirlpool's live state. +struct WhirlpoolFork { + elf: Vec, + key: Pubkey, + data: Vec, + mint_a: Pubkey, + mint_b: Pubkey, + vault_a: (Pubkey, Vec), + vault_b: (Pubkey, Vec), + /// Tick arrays keyed by start index, only those that exist on chain. + arrays: Vec<(i32, Pubkey, Vec)>, + start: i32, + per_array: i32, +} + +impl WhirlpoolFork { + /// The three tick arrays a swap in `a_to_b` order must be handed, in sequence from the current + /// one. Uninitialized neighbours are replaced by repeating the last existing array, which is what + /// Orca's own SDK does - the program only requires the sequence be valid for the direction. + fn tick_arrays(&self, a_to_b: bool) -> Vec { + let step = if a_to_b { + -self.per_array + } else { + self.per_array + }; + let mut out = Vec::new(); + for k in 0..3 { + let want = self.start + step * k; + let found = self + .arrays + .iter() + .find(|(s, _, _)| *s == want) + .map(|(_, k, _)| *k); + match found { + Some(k) => out.push(k), + None => out.push(*out.last().expect("the current array must exist")), + } + } + out + } +} + +async fn whirlpool_fork(pool: &str) -> WhirlpoolFork { + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let key = Pubkey::from_str_const(pool); + let data = fetch(&[pool]).await.remove(0); + let spacing = u16::from_le_bytes(data[41..43].try_into().unwrap()); + let tick_current = i32::from_le_bytes(data[81..85].try_into().unwrap()); + let mint_a = Pubkey::try_from(&data[101..133]).expect("mint_a"); + let vault_a_key = Pubkey::try_from(&data[133..165]).expect("vault_a"); + let mint_b = Pubkey::try_from(&data[181..213]).expect("mint_b"); + let vault_b_key = Pubkey::try_from(&data[213..245]).expect("vault_b"); + + let per_array = spacing as i32 * 88; + let start = (tick_current as f32 / per_array as f32).floor() as i32 * per_array; + + // Two arrays below and one above, so either direction has a sequence to walk. + let starts: Vec = (-2..=1).map(|k| start + k * per_array).collect(); + let array_keys: Vec = starts + .iter() + .map(|s| { + Pubkey::find_program_address( + &[b"tick_array", key.as_ref(), s.to_string().as_bytes()], + &prog, + ) + .0 + }) + .collect(); + + let mut addrs: Vec = array_keys.iter().map(|k| k.to_string()).collect(); + addrs.push(vault_a_key.to_string()); + addrs.push(vault_b_key.to_string()); + let refs: Vec<&str> = addrs.iter().map(|s| s.as_str()).collect(); + let got = fetch_optional(&refs).await; + + let arrays: Vec<(i32, Pubkey, Vec)> = starts + .iter() + .zip(array_keys.iter()) + .zip(got.iter()) + .filter_map(|((s, k), d)| d.as_ref().map(|d| (*s, *k, d.clone()))) + .collect(); + assert!( + arrays.iter().any(|(s, _, _)| *s == start), + "{pool}: the tick array holding the current tick does not exist, so no swap can be replayed" + ); + + WhirlpoolFork { + elf: whirlpool_elf().await, + key, + data, + mint_a, + mint_b, + vault_a: (vault_a_key, got[4].clone().expect("vault_a exists")), + vault_b: (vault_b_key, got[5].clone().expect("vault_b exists")), + arrays, + start, + per_array, + } +} + +/// Executes a Whirlpool swap in LiteSVM against forked mainnet state. +/// +/// Returns `(amount_in_spent, amount_out_received)` measured from the taker's own token accounts. +fn whirlpool_replay( + fork: &WhirlpoolFork, + amount_in: u64, + a_to_b: bool, + min_out: u64, +) -> Result<(u64, u64), String> { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let spl = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(prog, &fork.elf) + .map_err(|e| format!("add_program: {e:?}"))?; + + // The pool accrues rewards against wall-clock time and refuses to run if the clock is behind its + // own `reward_last_updated_timestamp` (error 6022, InvalidTimestamp). LiteSVM starts near zero, + // which is millions of seconds behind any forked mainnet account, so the clock has to be advanced + // to the pool's own notion of now. + let pool_ts = u64::from_le_bytes(fork.data[261..269].try_into().unwrap()); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.unix_timestamp = pool_ts as i64; + clock.slot = 300_000_000; + svm.set_sysvar(&clock); + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(fork.key, owned(fork.data.clone(), prog)) + .map_err(|e| format!("seed whirlpool: {e:?}"))?; + for (_, key, data) in &fork.arrays { + svm.set_account(*key, owned(data.clone(), prog)) + .map_err(|e| format!("seed tick array: {e:?}"))?; + } + for (key, data) in [&fork.vault_a, &fork.vault_b] { + svm.set_account(*key, owned(data.clone(), spl)) + .map_err(|e| format!("seed vault: {e:?}"))?; + } + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("airdrop: {e:?}"))?; + // The taker starts funded on the side they are selling and empty on the side they are buying, so + // the balances below measure the swap and nothing else. + let (ta_a, ta_b) = (Pubkey::new_unique(), Pubkey::new_unique()); + let (amt_a, amt_b) = if a_to_b { + (amount_in.saturating_mul(2), 0) + } else { + (0, amount_in.saturating_mul(2)) + }; + svm.set_account( + ta_a, + owned(token_account(&fork.mint_a, &taker.pubkey(), amt_a), spl), + ) + .map_err(|e| format!("seed taker a: {e:?}"))?; + svm.set_account( + ta_b, + owned(token_account(&fork.mint_b, &taker.pubkey(), amt_b), spl), + ) + .map_err(|e| format!("seed taker b: {e:?}"))?; + + let (oracle, _) = Pubkey::find_program_address(&[b"oracle", fork.key.as_ref()], &prog); + let arrays = fork.tick_arrays(a_to_b); + // Account order is the IDL's, exactly: see `whirlpool_swap`. + let metas = vec![ + AccountMeta::new_readonly(spl, false), + AccountMeta::new_readonly(taker.pubkey(), true), + AccountMeta::new(fork.key, false), + AccountMeta::new(ta_a, false), + AccountMeta::new(fork.vault_a.0, false), + AccountMeta::new(ta_b, false), + AccountMeta::new(fork.vault_b.0, false), + AccountMeta::new(arrays[0], false), + AccountMeta::new(arrays[1], false), + AccountMeta::new(arrays[2], false), + AccountMeta::new_readonly(oracle, false), + AccountMeta::new_readonly(prog, false), + ]; + let limit = if a_to_b { + whirlpool_swap::MIN_SQRT_PRICE + } else { + whirlpool_swap::MAX_SQRT_PRICE + }; + let swap = Instruction { + program_id: prog, + accounts: metas, + data: whirlpool_swap::data(amount_in, min_out, limit, true, a_to_b), + }; + // Crossing tick arrays costs well over the 200k default. + let mut budget = vec![2u8]; + budget.extend_from_slice(&600_000u32.to_le_bytes()); + let cu = Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }; + + let before_a = spl_amount(&svm.get_account(&ta_a).expect("ta_a").data); + let before_b = spl_amount(&svm.get_account(&ta_b).expect("ta_b").data); + let tx = Transaction::new_signed_with_payer( + &[cu, swap], + Some(&taker.pubkey()), + &[&taker], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .map_err(|e| format!("{:?}", e.err))?; + let after_a = spl_amount(&svm.get_account(&ta_a).expect("ta_a").data); + let after_b = spl_amount(&svm.get_account(&ta_b).expect("ta_b").data); + + if a_to_b { + Ok((before_a - after_a, after_b - before_b)) + } else { + Ok((before_b - after_b, after_a - before_a)) + } +} + +/// A real Orca Whirlpool swap executes against forked mainnet state, in both directions. +/// +/// This is the AMM leg the cross-venue arbitrage scenario needs, and it is also what validates the +/// instruction layout transcribed in `whirlpool_swap`: the assertions below pin all four balances that +/// move, so a wrong account order or argument encoding cannot pass by coincidence. +#[tokio::test] +async fn whirlpool_swap_executes_against_forked_state() { + let fork = whirlpool_fork(WHIRLPOOL_SOL_USDC).await; + // 1 SOL. Small enough to stay inside the current tick array on a pool this deep, which keeps the + // test about the instruction rather than about tick-crossing. + const ONE_SOL: u64 = 1_000_000_000; + + let (spent, got) = whirlpool_replay(&fork, ONE_SOL, true, 0).expect("a_to_b swap must execute"); + assert_eq!( + spent, ONE_SOL, + "the swap must consume exactly the input it was given" + ); + assert!(got > 0, "selling 1 SOL must return USDC"); + + // Sanity-check the rate against the pool's own published price rather than a hardcoded number, so + // this does not rot as SOL moves. sqrt_price is Q64.64 over raw units. + let sqrt_price = u128::from_le_bytes(fork.data[65..81].try_into().unwrap()); + let price_raw = (sqrt_price as f64 / 2f64.powi(64)).powi(2); // USDC-raw per SOL-raw + let expected = ONE_SOL as f64 * price_raw; + let ratio = got as f64 / expected; + assert!( + (0.97..=1.0).contains(&ratio), + "1 SOL returned {got} USDC-raw where the pool's own sqrt_price implies about {expected:.0}; \ + ratio {ratio:.4} is outside the fee-and-slippage band, so the swap is not pricing off this \ + pool's state" + ); + + // The other direction, sized from what the first leg produced so it is the same notional. + let (spent_b, got_b) = + whirlpool_replay(&fork, got, false, 0).expect("b_to_a swap must execute"); + assert_eq!( + spent_b, got, + "the reverse swap must consume exactly its input" + ); + assert!( + got_b > 0 && got_b < ONE_SOL, + "round-tripping must return less than the 1 SOL it started with after fees, got {got_b}" + ); + + // And the threshold argument is enforced, which the arbitrage test relies on for its profit floor. + let greedy = whirlpool_replay(&fork, ONE_SOL, true, got + 1); + assert!( + greedy.is_err(), + "asking for more than the swap can deliver must revert, got {greedy:?}" + ); +} + +/// Buys a fixed quantity of the base asset on Orca and sells it on BisonFi in ONE transaction. +/// +/// `dislocation` scales BisonFi's published mid, so a value above 1.0 makes BisonFi the richer bid and +/// the round trip profitable. Returns the taker's net change in the quote asset - negative is a loss. +/// +/// The two legs are coupled by using an exact-OUTPUT swap on Orca: an instruction's amounts are fixed +/// when the transaction is built, so a leg that bought "whatever N USDC gets" could not be followed by +/// a leg that sells exactly that. Asking Orca for exactly N base tokens and paying whatever it costs +/// makes the second leg's size known in advance, which is what lets both legs sit in one transaction. +async fn bisonfi_orca_atomic_arb( + bisonfi_pool: &str, + base_out: u64, + dislocation: f64, +) -> Result { + use litesvm::LiteSVM; + use solana_account::Account; + use solana_instruction::{AccountMeta, Instruction}; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let orca = whirlpool_fork(WHIRLPOOL_SOL_USDC).await; + let bf_elf = bisonfi_elf().await; + let mut bf = fetch(&[bisonfi_pool]).await.remove(0); + let bf_programs = bisonfi_token_programs(&[bf.clone()]).await.remove(0); + + let g64 = |b: &[u8], o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap()); + let base_reserve = g64(&bf, 48); + let quote_reserve = g64(&bf, 56); + let bf_base_vault = Pubkey::new_from_array(bf[120..152].try_into().unwrap()); + let bf_quote_vault = Pubkey::new_from_array(bf[152..184].try_into().unwrap()); + let base_mint = Pubkey::new_from_array(bf[184..216].try_into().unwrap()); + let quote_mint = Pubkey::new_from_array(bf[216..248].try_into().unwrap()); + let bf_slot = g64(&bf, 72); + + // Both venues have to be quoting the same pair in the same order, or the shared token accounts + // below would be silently routing two unrelated markets. + assert_eq!( + (orca.mint_a, orca.mint_b), + (base_mint, quote_mint), + "the Orca pool and the BisonFi market must quote the same base/quote pair" + ); + + // Dislocate BisonFi's mid through the shipped template. + if dislocation != 1.0 { + let mid = u128::from_le_bytes(bf[832..848].try_into().unwrap()); + let moved = (mid as f64 * dislocation) as u128; + bisonfi_apply_template( + "bisonfi-fair-value", + &[("fair_value", serde_json::json!(moved.to_string()))], + )(&mut bf); + } + + let bf_prog = Pubkey::from_str_const(BISONFI_PROGRAM); + let orca_prog = Pubkey::from_str_const(WHIRLPOOL_PROGRAM); + let spl = Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + let bf_key = Pubkey::from_str_const(bisonfi_pool); + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(bf_prog, &bf_elf) + .map_err(|e| format!("add bisonfi: {e:?}"))?; + svm.add_program(orca_prog, &orca.elf) + .map_err(|e| format!("add orca: {e:?}"))?; + + // One clock satisfies both venues: BisonFi checks the SLOT against its own last_update_slot and + // Orca checks the TIMESTAMP against its reward accrual, so the two constraints do not collide. + let orca_ts = u64::from_le_bytes(orca.data[261..269].try_into().unwrap()); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = bf_slot; + clock.unix_timestamp = orca_ts as i64; + svm.set_sysvar(&clock); + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|e| format!("set last_restart_slot: {e:?}"))?; + + let owned = |data: Vec, owner: Pubkey| Account { + lamports: 10_000_000_000, + data, + owner, + executable: false, + rent_epoch: 0, + }; + svm.set_account(orca.key, owned(orca.data.clone(), orca_prog)) + .map_err(|e| format!("{e:?}"))?; + for (_, k, d) in &orca.arrays { + svm.set_account(*k, owned(d.clone(), orca_prog)) + .map_err(|e| format!("{e:?}"))?; + } + for (k, d) in [&orca.vault_a, &orca.vault_b] { + svm.set_account(*k, owned(d.clone(), spl)) + .map_err(|e| format!("{e:?}"))?; + } + svm.set_account(bf_key, owned(bf, bf_prog)) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + bf_base_vault, + owned( + token_account(&base_mint, &bf_key, base_reserve), + bf_programs.0, + ), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + bf_quote_vault, + owned( + token_account("e_mint, &bf_key, quote_reserve + 79_168), + bf_programs.1, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + // The arbitrageur: funded in the quote asset, empty in the base. Both legs share these two + // accounts, which is what makes the profit measurable as a single balance change. + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|e| format!("{e:?}"))?; + let (base_ta, quote_ta) = (Pubkey::new_unique(), Pubkey::new_unique()); + let quote_funding = quote_reserve / 4; + svm.set_account( + base_ta, + owned(token_account(&base_mint, &taker.pubkey(), 0), spl), + ) + .map_err(|e| format!("{e:?}"))?; + svm.set_account( + quote_ta, + owned( + token_account("e_mint, &taker.pubkey(), quote_funding), + spl, + ), + ) + .map_err(|e| format!("{e:?}"))?; + + let (oracle, _) = Pubkey::find_program_address(&[b"oracle", orca.key.as_ref()], &orca_prog); + let arrays = orca.tick_arrays(false); // buying base means B -> A + let buy_on_orca = Instruction { + program_id: orca_prog, + accounts: vec![ + AccountMeta::new_readonly(spl, false), + AccountMeta::new_readonly(taker.pubkey(), true), + AccountMeta::new(orca.key, false), + AccountMeta::new(base_ta, false), + AccountMeta::new(orca.vault_a.0, false), + AccountMeta::new(quote_ta, false), + AccountMeta::new(orca.vault_b.0, false), + AccountMeta::new(arrays[0], false), + AccountMeta::new(arrays[1], false), + AccountMeta::new(arrays[2], false), + AccountMeta::new_readonly(oracle, false), + AccountMeta::new_readonly(orca_prog, false), + ], + // Exact output: `base_out` of token A, paying up to u64::MAX of token B. + data: whirlpool_swap::data( + base_out, + u64::MAX, + whirlpool_swap::MAX_SQRT_PRICE, + false, + false, + ), + }; + + let mut bf_data = Vec::with_capacity(19); + bf_data.push(0x07); + bf_data.extend_from_slice(&base_out.to_le_bytes()); + bf_data.extend_from_slice(&0u64.to_le_bytes()); // min_out; profit is asserted on balances + bf_data.push(0); // direction 0 = sell base for quote + bf_data.push(0); + let sell_on_bisonfi = Instruction { + program_id: bf_prog, + accounts: vec![ + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(bf_key, false), + AccountMeta::new(bf_base_vault, false), + AccountMeta::new(bf_quote_vault, false), + AccountMeta::new(base_ta, false), + AccountMeta::new(quote_ta, false), + AccountMeta::new_readonly(bf_programs.0, false), + AccountMeta::new_readonly(bf_programs.1, false), + AccountMeta::new_readonly(Pubkey::from_str_const(BISONFI_NINTH), true), + ], + data: bf_data, + }; + + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_800_000u32.to_le_bytes()); + let ixs = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + buy_on_orca, + sell_on_bisonfi, + ]; + + let before = spl_amount(&svm.get_account("e_ta).expect("quote_ta").data); + let mut msg = solana_message::Message::new(&ixs, Some(&taker.pubkey())); + msg.recent_blockhash = svm.latest_blockhash(); + let nsig = msg.header.num_required_signatures as usize; + let mut tx = Transaction::new_unsigned(msg); + tx.signatures = vec![solana_signature::Signature::default(); nsig]; + tx.signatures[0] = taker.sign_message(&tx.message.serialize()); + svm.send_transaction(tx) + .map_err(|e| format!("{:?}", e.err))?; + + let after = spl_amount(&svm.get_account("e_ta).expect("quote_ta").data); + let leftover = spl_amount(&svm.get_account(&base_ta).expect("base_ta").data); + assert_eq!( + leftover, 0, + "the arbitrageur must end flat in the base asset, or the profit below is really an \ + unrealized position: {leftover} left over" + ); + Ok(after as i64 - before as i64) +} + +/// SCENARIO: arbitrage between BisonFi and an AMM on the same pair, executed atomically. +/// +/// The upgrade over `bisonfi_scenario_arbitrage_against_an_amm`, which compares the two venues' quotes +/// without trading: here both legs run in a single transaction against forked mainnet state for both +/// programs, and the profit is a real balance change in the arbitrageur's own account. +/// +/// Self-validating in both directions. At the market's true mid the round trip must LOSE money, since +/// the arbitrageur pays fees on both venues - if that leg showed a profit, the harness would be minting +/// value and every number it produced would be suspect. Only once the fair-value template dislocates +/// BisonFi does the same transaction become profitable, and the profit has to grow with the +/// dislocation. +#[tokio::test] +async fn bisonfi_scenario_atomic_arbitrage_against_orca() { + const SOL_USDC: &str = "8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo"; + const ONE_SOL: u64 = 1_000_000_000; + + // No dislocation: buying on Orca and selling on BisonFi at the true mid must not pay. + let fair = bisonfi_orca_atomic_arb(SOL_USDC, ONE_SOL, 1.0) + .await + .expect("the round trip must execute at the true mid"); + assert!( + fair < 0, + "buying on Orca and selling on BisonFi at the true mid returned a profit of {fair}. Two \ + venues both charging a fee cannot pay the taker, so the harness is not measuring a real \ + round trip" + ); + + // Mark BisonFi up so it becomes the richer bid, and the same transaction becomes an arbitrage. + let mut last = fair; + for pct in [2.0f64, 5.0, 10.0] { + let profit = bisonfi_orca_atomic_arb(SOL_USDC, ONE_SOL, 1.0 + pct / 100.0) + .await + .unwrap_or_else(|e| { + panic!("the round trip must execute with BisonFi {pct}% rich: {e}") + }); + assert!( + profit > last, + "marking BisonFi up {pct}% must pay better than the {last} the previous step returned, \ + got {profit}" + ); + last = profit; + } + assert!( + last > 0, + "a 10% dislocation must produce an outright profit, got {last}. The fair-value template's \ + guidance claims this lever creates a cross-venue arbitrage, so it has to actually do so" + ); +} + +/// A stale quote suppresses the price and spread levers entirely, on every market that quotes. +/// +/// This is a PRECEDENCE property: the freshness gate is evaluated before the venue consults its mid +/// or its ladder, so an override that lands byte-perfectly in the account has no effect at all and +/// the transaction still succeeds. It is the most consequential thing to know about combining these +/// templates, and the failure it describes is invisible - no revert, no log, correct bytes. +/// +/// It is also the property most likely to break silently. If a redeploy ever evaluated the quote +/// before the freshness check, every scenario in this suite would keep passing while meaning +/// something different. +/// +/// The fresh leg is what stops this passing vacuously: doubling the published mid on a fresh market +/// has to double the fill, so a run where everything returned zero fails rather than looking green. +#[tokio::test] +async fn bisonfi_staleness_suppresses_the_price_and_spread_levers() { + /// Comfortably past the two-slot cliff. + const STALE_BY: u64 = 5; + + let rig = bisonfi_rig().await; + let mut checked = 0usize; + + for (pool, data, tp) in &rig.quoting { + let size = BisonfiRig::sell_size(data); + let published = u64::from_le_bytes(data[72..80].try_into().unwrap()); + let mid = u128::from_le_bytes(data[832..848].try_into().unwrap()); + let doubled = mid * 2; + let double_mid = || { + bisonfi_apply_template( + "bisonfi-fair-value", + &[("fair_value", serde_json::json!(doubled.to_string()))], + ) + }; + + let baseline = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, |_| {}) + .unwrap_or_else(|e| panic!("{pool}: control sell must price: {e}")); + assert!(baseline > 0, "{pool}: control sell returned nothing"); + + // Fresh: the price lever works. Without this leg the assertions below would be satisfied by + // a market that simply never quotes. + let fresh_doubled = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, double_mid()) + .unwrap_or_else(|e| panic!("{pool}: fresh market with a doubled mid must price: {e}")); + let ratio = fresh_doubled as f64 / baseline as f64; + assert!( + (1.9..=2.1).contains(&ratio), + "{pool}: doubling the mid on a FRESH market should about double the fill, got \ + {fresh_doubled} against {baseline} (ratio {ratio:.3}). The price lever is not working, \ + so this test cannot say anything about staleness suppressing it" + ); + + // Stale: the same override, byte-identical, now does nothing. + let stale_doubled = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let apply = double_mid(); + move |d: &mut Vec| { + d[72..80].copy_from_slice(&(published - STALE_BY).to_le_bytes()); + apply(d); + } + }) + .unwrap_or(0); + assert_eq!( + stale_doubled, 0, + "{pool}: a market {STALE_BY} slots stale must ignore a doubled mid, but it paid \ + {stale_doubled}. The freshness gate no longer runs first, and every scenario that sets \ + a price after spending slots would now behave differently" + ); + + // And the same for the spread lever. + let stale_spread = bisonfi_replay(&rig.elf, pool, data, *tp, size, 0, { + let apply = bisonfi_apply_template("bisonfi-spread", &bisonfi_spread_bids(-13)); + move |d: &mut Vec| { + d[72..80].copy_from_slice(&(published - STALE_BY).to_le_bytes()); + apply(d); + } + }) + .unwrap_or(0); + assert_eq!( + stale_spread, 0, + "{pool}: a market {STALE_BY} slots stale must ignore a spread override, but it paid \ + {stale_spread}" + ); + + checked += 1; + } + + assert!( + checked >= 6, + "only {checked} markets exercised the precedence of the freshness gate" + ); +} diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs index 9bdc0c1a..17cbdee6 100644 --- a/crates/core/src/tests/kamino/mod.rs +++ b/crates/core/src/tests/kamino/mod.rs @@ -52,10 +52,27 @@ async fn fetch(addresses: &[&str]) -> Vec> { .map(|a| Pubkey::from_str_const(a)) .collect(); - client - .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) - .await - .unwrap_or_else(|e| panic!("failed to fetch {addresses:?} from mainnet: {e}")) + // The public endpoint throttles and intermittently 503s, which has nothing to do with what these + // tests assert. Retry a few times with backoff so a transient refusal is not read as a failure. + let mut attempt = 0; + let results = loop { + match client + .get_multiple_accounts(&pubkeys, CommitmentConfig::confirmed()) + .await + { + Ok(r) => break r, + Err(e) => { + attempt += 1; + if attempt >= 5 { + panic!( + "failed to fetch {addresses:?} from mainnet after {attempt} attempts: {e}" + ); + } + tokio::time::sleep(std::time::Duration::from_millis(750 * attempt)).await; + } + } + }; + results .into_iter() .zip(addresses) .map(|(result, address)| match result { @@ -107,7 +124,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { .unwrap_or_else(|| panic!("template {template_id} should exist")); let account_def = template - .idl + .idl() .accounts .iter() .find(|a| a.name == *account_name) @@ -119,7 +136,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { ); let forged = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, data, template.idl(), &HashMap::new()) .unwrap_or_else(|e| { panic!( "live mainnet {account_name} failed to decode/re-encode with the bundled \ @@ -167,7 +184,7 @@ async fn override_on_real_account_touches_only_target_bytes() { .get_forged_account_data( &pubkey, reserve_data, - &reserve.idl, + reserve.idl(), &HashMap::from([( "config.liquidation_threshold_pct".to_string(), serde_json::json!(50u8), @@ -201,7 +218,7 @@ async fn override_on_real_account_touches_only_target_bytes() { .get_forged_account_data( &pubkey, scope_data, - &scope.idl, + scope.idl(), &HashMap::from([( format!("prices.{IDX}.price.value"), serde_json::json!(new_value), @@ -359,7 +376,7 @@ async fn every_template_round_trips_over_a_live_account() { .filter(|t| t.account_type == *account_type) { let identity = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, data, template.idl(), &HashMap::new()) .unwrap_or_else(|e| { panic!( "identity round-trip failed for {} ({address}): {e}", @@ -383,7 +400,7 @@ async fn every_template_round_trips_over_a_live_account() { let mut overrides: HashMap = HashMap::new(); for property in &template.properties { let ty = surfpool_types::resolve_idl_type( - &template.idl, + template.idl(), &template.account_type, &property.path, ) @@ -397,7 +414,7 @@ async fn every_template_round_trips_over_a_live_account() { } let forged = surfnet_svm - .get_forged_account_data(&pubkey, data, &template.idl, &overrides) + .get_forged_account_data(&pubkey, data, template.idl(), &overrides) .unwrap_or_else(|e| { panic!( "forge failed for {} with {} scalar override(s): {e}", @@ -460,7 +477,7 @@ async fn obligation_array_index_and_pubkey_overrides() { ]); let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .get_forged_account_data(&Pubkey::new_unique(), &data, template.idl(), &overrides) .expect("array-index and pubkey overrides should apply"); assert_eq!(forged.len(), data.len(), "account size must be preserved"); @@ -524,7 +541,7 @@ async fn scope_price_override_writes_expected_bytes() { ]); let forged = surfnet_svm - .get_forged_account_data(&Pubkey::new_unique(), &data, &template.idl, &overrides) + .get_forged_account_data(&Pubkey::new_unique(), &data, template.idl(), &overrides) .expect("scope price override should apply"); assert_eq!(forged.len(), data.len(), "account size must be preserved"); @@ -574,7 +591,7 @@ async fn farms_reward_override_writes_both_halves() { ), ]); let forged_farm = surfnet_svm - .get_forged_account_data(&pubkey, farm_data, &farm.idl, &farm_overrides) + .get_forged_account_data(&pubkey, farm_data, farm.idl(), &farm_overrides) .expect("farm accumulator override should apply"); assert_eq!(forged_farm.len(), farm_data.len()); assert_ne!(&forged_farm, farm_data); @@ -601,7 +618,7 @@ async fn farms_reward_override_writes_both_halves() { ), ]); let forged_user = surfnet_svm - .get_forged_account_data(&pubkey, user_data, &user.idl, &user_overrides) + .get_forged_account_data(&pubkey, user_data, user.idl(), &user_overrides) .expect("user reward override should apply"); assert_eq!(forged_user.len(), user_data.len()); @@ -644,7 +661,7 @@ async fn liquidation_setup_writes_durable_inputs() { (format!("prices.{IDX}.price.exp"), serde_json::json!(8u64)), ]); let forged_scope = surfnet_svm - .get_forged_account_data(&pubkey, scope_data, &scope.idl, &scope_overrides) + .get_forged_account_data(&pubkey, scope_data, scope.idl(), &scope_overrides) .expect("scope crash should apply"); let off = SCOPE_PRICES_BASE + IDX * DATED_PRICE_SIZE; @@ -675,7 +692,7 @@ async fn liquidation_setup_writes_durable_inputs() { ), ]); let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, reserve_data, &reserve.idl, &reserve_overrides) + .get_forged_account_data(&pubkey, reserve_data, reserve.idl(), &reserve_overrides) .expect("reserve config override should apply"); assert_eq!( @@ -709,7 +726,7 @@ async fn withdraw_ticket_and_queue_cursor() { .get("kamino-withdraw-ticket") .expect("withdraw ticket template"); let ticket_disc = &ticket - .idl + .idl() .accounts .iter() .find(|a| a.name == "WithdrawTicket") @@ -727,7 +744,7 @@ async fn withdraw_ticket_and_queue_cursor() { ("invalid".to_string(), serde_json::json!(0u8)), ]); let forged_ticket = surfnet_svm - .get_forged_account_data(&pubkey, &ticket_data, &ticket.idl, &ticket_overrides) + .get_forged_account_data(&pubkey, &ticket_data, ticket.idl(), &ticket_overrides) .expect("withdraw ticket override should apply"); assert_eq!( u64::from_le_bytes(forged_ticket[8..16].try_into().unwrap()), @@ -758,7 +775,7 @@ async fn withdraw_ticket_and_queue_cursor() { ), ]); let forged_reserve = surfnet_svm - .get_forged_account_data(&pubkey, &reserve_data, &limits.idl, &queue_overrides) + .get_forged_account_data(&pubkey, &reserve_data, limits.idl(), &queue_overrides) .expect("withdraw queue override should apply"); assert_eq!(forged_reserve.len(), reserve_data.len()); diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index b2dd3792..eeb64407 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "integration-tests")] +pub mod bisonfi; pub mod helpers; pub mod integration; #[cfg(feature = "integration-tests")] diff --git a/crates/core/src/tests/pump/mod.rs b/crates/core/src/tests/pump/mod.rs index 960ce8e2..65066276 100644 --- a/crates/core/src/tests/pump/mod.rs +++ b/crates/core/src/tests/pump/mod.rs @@ -157,9 +157,12 @@ async fn real_mainnet_accounts_round_trip_unchanged() { let template = registry .get(template_id) .unwrap_or_else(|| panic!("template {template_id} should exist")); - - let account_def = template + let idl = template .idl + .as_ref() + .unwrap_or_else(|| panic!("Pump template {template_id} must carry an IDL")); + + let account_def = idl .accounts .iter() .find(|a| a.name == *account_name) @@ -171,7 +174,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { ); let forged = surfnet_svm - .get_forged_account_data(&pubkey, &account.data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, &account.data, idl, &HashMap::new()) .unwrap_or_else(|e| { panic!( "live mainnet {account_name} {address} failed to decode/re-encode with the \ @@ -226,8 +229,12 @@ async fn override_on_real_account_touches_only_target_bytes() { ), ("complete".to_string(), serde_json::json!(true)), ]); + let curve_idl = curve + .idl + .as_ref() + .expect("Pump curve template must carry an IDL"); let forged = surfnet_svm - .get_forged_account_data(&pubkey, curve_data, &curve.idl, &overrides) + .get_forged_account_data(&pubkey, curve_data, curve_idl, &overrides) .expect("curve override on the live bonding curve"); assert_eq!( forged.len(), @@ -265,8 +272,12 @@ async fn override_on_real_account_touches_only_target_bytes() { serde_json::json!(5_000_000_000i64), ), ]); + let pool_idl = pool + .idl + .as_ref() + .expect("Pump AMM template must carry an IDL"); let forged = surfnet_svm - .get_forged_account_data(&pubkey, pool_data, &pool.idl, &overrides) + .get_forged_account_data(&pubkey, pool_data, pool_idl, &overrides) .expect("pool override on the live canonical pool"); assert_eq!( forged.len(), diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index a04ca80a..8c3a5280 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -277,6 +277,12 @@ pub struct Property { /// For constant_ref type: the name of the constant definition to use #[serde(default, skip_serializing_if = "Option::is_none")] pub constant: Option, + /// Raw-layout only: byte offset of this field within the account. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Raw-layout only: how this field's bytes are produced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encoding: Option, } impl Property { @@ -288,6 +294,8 @@ impl Property { label: None, description: None, constant: None, + offset: None, + encoding: None, } } @@ -299,6 +307,8 @@ impl Property { label: None, description: None, constant: Some(constant.into()), + offset: None, + encoding: None, } } @@ -383,8 +393,11 @@ pub struct OverrideTemplate { pub description: String, /// Protocol this template is for (e.g., "Pyth", "Switchboard") pub protocol: String, - /// IDL for the account structure - defines all available fields and types - pub idl: Idl, + /// IDL for the account structure - defines all available fields and types. + /// + /// `None` for programs that publish no IDL and are written through `raw_layout` instead. Those + /// templates cannot use the IDL write path at all, so there is nothing to reconstruct here. + pub idl: Option, /// How to determine the account address pub address: AccountAddress, /// Account type name from the IDL (e.g., "PriceAccount") @@ -401,9 +414,27 @@ pub struct OverrideTemplate { /// This helps LLMs understand how to correctly use the template #[serde(default, skip_serializing_if = "Option::is_none")] pub llm_context: Option, + /// Set for programs with no usable IDL. When present the override engine writes bytes at + /// each property's offset instead of decoding and re-encoding through the IDL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_layout: Option, } impl OverrideTemplate { + /// The IDL this template was built from. + /// + /// Panics for templates that have none - those belong to programs that publish no IDL and are + /// written through `raw_layout`. Callers that may legitimately see either must match on the + /// field instead of calling this. + pub fn idl(&self) -> &Idl { + self.idl.as_ref().unwrap_or_else(|| { + panic!( + "template {} has no IDL; it is written through raw_layout", + self.id + ) + }) + } + pub fn new( id: String, name: String, @@ -419,13 +450,14 @@ impl OverrideTemplate { name, description, protocol, - idl, + idl: Some(idl), address, account_type, properties, constants: HashMap::new(), tags: Vec::new(), llm_context: None, + raw_layout: None, } } @@ -642,7 +674,8 @@ pub struct YamlOverrideTemplateFile { pub properties: Vec, #[serde(default)] pub constants: HashMap, - pub idl_file_path: String, + #[serde(default)] + pub idl_file_path: Option, pub address: YamlAccountAddress, #[serde(default)] pub tags: Vec, @@ -659,7 +692,7 @@ impl YamlOverrideTemplateFile { name: self.name, description: self.description, protocol: self.protocol, - idl, + idl: Some(idl), address: self.address.into(), account_type: self.account_type, properties: self.properties.into_iter().map(Into::into).collect(), @@ -670,6 +703,7 @@ impl YamlOverrideTemplateFile { .collect(), tags: self.tags, llm_context: self.llm_context, + raw_layout: None, } } } @@ -850,6 +884,12 @@ pub enum YamlProperty { /// For constant_ref type: the name of the constant definition to use #[serde(default)] constant: Option, + /// Raw-layout only: byte offset of this field within the account + #[serde(default)] + offset: Option, + /// Raw-layout only: how this field's bytes are produced + #[serde(default)] + encoding: Option, }, } @@ -863,6 +903,8 @@ impl From for Property { label, description, constant, + offset, + encoding, } => { let kind = match kind.as_deref() { Some("constant_ref") => PropertyKind::ConstantRef, @@ -874,6 +916,8 @@ impl From for Property { label, description, constant, + offset, + encoding, } } } @@ -924,14 +968,18 @@ pub struct YamlOverrideTemplateCollection { /// Account type name from the IDL (optional, can be overridden per template) #[serde(default)] pub account_type: Option, - /// Path to shared IDL file - pub idl_file_path: String, + /// Path to shared IDL file. Absent for programs that publish no IDL. + #[serde(default)] + pub idl_file_path: Option, /// Common tags for all templates #[serde(default)] pub tags: Vec, /// Protocol-specific constants shared by all templates in this collection #[serde(default)] pub constants: HashMap, + /// Byte layout, for programs with no usable IDL. Shared by every template in the collection. + #[serde(default)] + pub raw_layout: Option, /// The templates pub templates: Vec, } @@ -954,6 +1002,242 @@ pub struct YamlOverrideTemplateEntry { pub llm_context: Option, } +// ======================================== +// Raw byte layouts (programs with no usable IDL) +// ======================================== + +/// How a raw-layout field's bytes are produced. Every variant is integer-exact: values arrive as +/// JSON integers or decimal strings and are written little-endian, never routed through f64. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub enum RawEncoding { + U8, + U16, + U32, + U64, + U128, + I32, + I64, + I128, + /// A signed 32-bit value written to `count` slots, `stride` bytes apart. + /// + /// Exists because some layouts repeat one logical setting across a run of fixed-size records, and + /// exposing one property per record means exposing several that must agree - a worse footgun than + /// whatever it was meant to fix. BisonFi's quote ladder. + I32Strided { + count: usize, + stride: usize, + }, + /// A base58 pubkey, written as 32 bytes. + Bytes32, + /// The slot the override materializes at, plus `lead` (may be negative). + Slot { + lead: i64, + }, +} + +impl RawEncoding { + /// Byte width of this encoding. + pub fn width(&self) -> usize { + match self { + RawEncoding::U8 => 1, + RawEncoding::U16 => 2, + RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, + RawEncoding::U64 | RawEncoding::I64 | RawEncoding::Slot { .. } => 8, + RawEncoding::U128 | RawEncoding::I128 => 16, + RawEncoding::Bytes32 => 32, + } + } + + /// How many times the encoded value is written, and the byte step between writes. + /// + /// Every scalar writes once. Returning this uniformly lets `materialize` place strided and scalar + /// encodings with the same loop instead of special-casing one of them. + pub fn placements(&self) -> (usize, usize) { + match self { + RawEncoding::I32Strided { count, stride } => (*count, *stride), + other => (1, other.width()), + } + } + + /// The little-endian bytes for `value`. `target_slot` is only read by [`RawEncoding::Slot`]. + pub fn encode(&self, value: &serde_json::Value, target_slot: Slot) -> Result, String> { + // Read the digits as text so nothing passes through f64, which cannot hold a u128 + // exactly. A decimal string is the only way to express values above u64::MAX in JSON. + let digits = |what: &str| -> Result { + match value { + serde_json::Value::Number(n) if n.as_u64().is_none() && n.as_i64().is_none() => { + Err(format!( + "{n} exceeds what a JSON number can hold exactly; pass this {what} as a \ + decimal string instead" + )) + } + serde_json::Value::Number(n) => Ok(n.to_string()), + serde_json::Value::String(s) => Ok(s.trim().to_string()), + other => Err(format!( + "expected a number or decimal string for {what}, found {other}" + )), + } + }; + macro_rules! int { + ($ty:ty, $what:expr) => {{ + let d = digits($what)?; + d.parse::<$ty>() + .map_err(|e| format!("invalid {}: '{d}': {e}", $what))? + .to_le_bytes() + .to_vec() + }}; + } + Ok(match self { + RawEncoding::U8 => int!(u8, "u8"), + RawEncoding::U16 => int!(u16, "u16"), + RawEncoding::U32 => int!(u32, "u32"), + RawEncoding::U64 => int!(u64, "u64"), + RawEncoding::U128 => int!(u128, "u128"), + RawEncoding::I32 | RawEncoding::I32Strided { .. } => int!(i32, "i32"), + RawEncoding::I64 => int!(i64, "i64"), + RawEncoding::I128 => int!(i128, "i128"), + RawEncoding::Bytes32 => { + let text = value + .as_str() + .ok_or_else(|| "expected a base58 pubkey string".to_string())?; + Pubkey::from_str(text) + .map_err(|e| format!("invalid pubkey '{text}': {e}"))? + .to_bytes() + .to_vec() + } + RawEncoding::Slot { lead } => { + let lead = match value { + serde_json::Value::Null => *lead, + _ => { + let d = digits("slot lead")?; + d.parse::() + .map_err(|e| format!("invalid slot lead: '{d}': {e}"))? + } + }; + let slot = if lead >= 0 { + target_slot.checked_add(lead as u64).ok_or_else(|| { + format!("slot {target_slot} plus lead {lead} exceeds u64::MAX") + })? + } else { + target_slot.checked_sub(lead.unsigned_abs()).unwrap_or(0) + }; + slot.to_le_bytes().to_vec() + } + }) + } +} + +/// Bytes that must be present for an account to be the one a raw layout describes. Without an +/// IDL there is no discriminator to resolve the type, so this is the only thing standing between +/// a raw write and silently corrupting an unrelated account. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub struct RawMagic { + pub offset: usize, + /// Expected bytes, as an ASCII string or a byte list. + pub bytes: Vec, +} + +/// A byte-level description of an account, used instead of an IDL. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +// Deliberately no `ts(export)`: override templates are not part of the TS surface, so the three +// raw-layout types have nothing referencing them there and exporting them produced no file. +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +pub struct RawLayout { + /// Exact account size. A mismatch means this is not the account the layout describes. + /// Serialized camelCase for the JSON API; the alias keeps the YAML snake_case like its peers. + #[serde(alias = "account_size")] + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub account_size: usize, + /// Optional type tag. Omit for programs that have none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub magic: Option, +} + +impl RawLayout { + /// Rejects an account that is not the shape this layout describes. + pub fn guard(&self, data: &[u8]) -> Result<(), String> { + if data.len() != self.account_size { + return Err(format!( + "account is {} bytes, the layout describes {}", + data.len(), + self.account_size + )); + } + if let Some(magic) = &self.magic { + let end = magic + .offset + .checked_add(magic.bytes.len()) + .ok_or_else(|| "magic offset overflow".to_string())?; + if end > data.len() || &data[magic.offset..end] != magic.bytes.as_slice() { + return Err(format!( + "magic bytes at offset {} do not match; this is not the expected account", + magic.offset + )); + } + } + Ok(()) + } + + /// Writes `values` into a copy of `data` using each property's offset and encoding. + pub fn materialize( + &self, + data: &[u8], + properties: &[Property], + values: &HashMap, + target_slot: Slot, + ) -> Result, String> { + self.guard(data)?; + let mut out = data.to_vec(); + for (name, value) in values { + let property = properties + .iter() + .find(|p| &p.path == name) + .ok_or_else(|| format!("'{name}' is not a property of this raw-layout template"))?; + let (Some(offset), Some(encoding)) = (property.offset, property.encoding.as_ref()) + else { + return Err(format!("property '{name}' has no offset or encoding")); + }; + let bytes = encoding.encode(value, target_slot)?; + let (count, stride) = encoding.placements(); + for i in 0..count { + let at = offset + .checked_add( + i.checked_mul(stride) + .ok_or_else(|| format!("stride overflow for '{name}'"))?, + ) + .ok_or_else(|| format!("offset overflow for '{name}'"))?; + let end = at + .checked_add(bytes.len()) + .ok_or_else(|| format!("offset overflow for '{name}'"))?; + if end > out.len() { + // Scalars keep the original wording; only a strided run needs to explain itself. + return Err(if count == 1 { + format!( + "'{name}' at offset {offset} + {} bytes exceeds the {} byte account", + bytes.len(), + out.len() + ) + } else { + format!( + "'{name}' writes {count} x {} bytes from offset {offset} every \ + {stride}, which exceeds the {} byte account", + bytes.len(), + out.len() + ) + }); + } + out[at..end].copy_from_slice(&bytes); + } + } + Ok(out) + } +} + /// Walks a dot-notation path: struct fields by name, array elements by index. /// /// Returns the last named field and the type at the path's end. They differ on a trailing index: @@ -1050,7 +1334,7 @@ fn idl_field_docs(idl: &Idl, account_type: &str, path: &str) -> Option { /// supply one, so field guidance is not written twice. fn describe_properties_from_idl( properties: Vec, - idl: &Idl, + idl: Option<&Idl>, account_type: &str, ) -> Vec { properties @@ -1058,7 +1342,10 @@ fn describe_properties_from_idl( .map(|yaml| { let mut property: Property = yaml.into(); if property.description.is_none() { - property.description = idl_field_docs(idl, account_type, &property.path); + // Only a fallback. A raw_layout collection with no IDL must spell out every + // description in the YAML, since there is no schema to borrow docs from. + property.description = + idl.and_then(|idl| idl_field_docs(idl, account_type, &property.path)); } property }) @@ -1067,7 +1354,7 @@ fn describe_properties_from_idl( impl YamlOverrideTemplateCollection { /// Convert collection to runtime OverrideTemplates with loaded IDL - pub fn to_override_templates(self, idl: Idl) -> Vec { + pub fn to_override_templates(self, idl: Option) -> Vec { // Convert constants once for sharing let constants: HashMap = self .constants @@ -1090,11 +1377,16 @@ impl YamlOverrideTemplateCollection { protocol: self.protocol.clone(), idl: idl.clone(), address: entry.address.into(), - properties: describe_properties_from_idl(entry.properties, &idl, &account_type), + properties: describe_properties_from_idl( + entry.properties, + idl.as_ref(), + &account_type, + ), account_type, constants: constants.clone(), tags: self.tags.clone(), llm_context: entry.llm_context, + raw_layout: self.raw_layout.clone(), } }) .collect() @@ -1132,7 +1424,7 @@ impl YamlOverrideTemplate { name: self.name, description: self.description, protocol: self.protocol, - idl: self.idl, + idl: Some(self.idl), address: self.address.into(), account_type: self.account_type, properties: self.properties.into_iter().map(Into::into).collect(), @@ -1143,6 +1435,7 @@ impl YamlOverrideTemplate { .collect(), tags: self.tags, llm_context: self.llm_context, + raw_layout: None, } } } @@ -1241,6 +1534,169 @@ mod tests { use super::PdaSeed; + /// The encoding layer must never route a value through f64: a 2^88-scaled price is a 29-digit + /// integer and f64 carries about 16 significant digits. + #[test] + fn raw_encoding_writes_large_values_exactly() { + use super::RawEncoding; + + let huge: u128 = 50u128 * (1u128 << 88); + let bytes = RawEncoding::U128 + .encode(&json!(huge.to_string()), 0) + .expect("decimal string"); + assert_eq!(u128::from_le_bytes(bytes.try_into().unwrap()), huge); + + // A bare JSON number that big has already lost digits, so it must be refused rather than + // silently written wrong. + let err = RawEncoding::U128 + .encode(&json!(1.152921504606847e21), 0) + .expect_err("an inexact JSON number must be refused"); + assert!(err.contains("decimal string"), "unexpected error: {err}"); + } + + #[test] + fn raw_encoding_handles_signed_and_slot_fields() { + use super::RawEncoding; + + let bytes = RawEncoding::I64.encode(&json!(-25599i64 << 32), 0).unwrap(); + assert_eq!(i64::from_le_bytes(bytes.try_into().unwrap()) >> 32, -25599); + + // The supplied value is the lead, so one property covers live and stale. + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-5), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 495); + + // The manifest lead is the default, used when no value is given. + let bytes = RawEncoding::Slot { lead: -1 } + .encode(&json!(null), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); + + // A lead that would go below zero clamps rather than wrapping. + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-10), 3) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); + + // Slot is a u64. Values above i64::MAX must not wrap through a signed cast and become zero. + let large_slot = i64::MAX as u64 + 1; + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), large_slot) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(-1), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX - 1); + + let bytes = RawEncoding::Slot { lead: 0 } + .encode(&json!(0), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); + + let err = RawEncoding::Slot { lead: 0 } + .encode(&json!(1), u64::MAX) + .expect_err("a positive lead must not wrap past u64::MAX"); + assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); + } + + #[test] + fn raw_layout_rejects_writes_past_the_end_of_the_account() { + use super::{Property, RawEncoding, RawLayout}; + + let layout = RawLayout { + account_size: 16, + magic: None, + }; + let mut property = Property::field("tail".to_string()); + property.offset = Some(12); + property.encoding = Some(RawEncoding::U64); + + let err = layout + .materialize( + &[0u8; 16], + &[property], + &HashMap::from([("tail".to_string(), json!(1))]), + 0, + ) + .expect_err("a field crossing the end must be refused"); + assert!(err.contains("exceeds"), "unexpected error: {err}"); + } + + #[test] + fn i32_strided_writes_every_slot_and_nothing_between() { + use super::{Property, RawEncoding, RawLayout}; + let layout = RawLayout { + account_size: 64, + magic: None, + }; + let mut property = Property::field("ticks".to_string()); + property.offset = Some(4); + property.encoding = Some(RawEncoding::I32Strided { + count: 3, + stride: 16, + }); + + let out = layout + .materialize( + &[0u8; 64], + &[property], + &HashMap::from([("ticks".to_string(), json!(-25_600))]), + 0, + ) + .expect("strided write"); + + for i in 0..3usize { + let at = 4 + i * 16; + assert_eq!( + i32::from_le_bytes(out[at..at + 4].try_into().unwrap()), + -25_600, + "slot {i} at offset {at} should carry the value" + ); + } + // Everything outside the three four-byte spans must be untouched. + let written: Vec = (0..3).flat_map(|i| (4 + i * 16)..(8 + i * 16)).collect(); + for (i, b) in out.iter().enumerate() { + if !written.contains(&i) { + assert_eq!( + *b, 0, + "byte {i} lies between strided slots and must not change" + ); + } + } + } + + #[test] + fn i32_strided_rejects_a_run_that_leaves_the_account() { + use super::{Property, RawEncoding, RawLayout}; + let layout = RawLayout { + account_size: 32, + magic: None, + }; + let mut property = Property::field("ticks".to_string()); + property.offset = Some(4); + property.encoding = Some(RawEncoding::I32Strided { + count: 3, + stride: 16, + }); + let err = layout + .materialize( + &[0u8; 32], + &[property], + &HashMap::from([("ticks".to_string(), json!(1))]), + 0, + ) + .expect_err("a run crossing the end must be refused"); + assert!(err.contains("exceeds"), "unexpected error: {err}"); + } + #[test] fn u16_be_ref_rejects_out_of_range_values() { let seed = PdaSeed::U16BeRef("index".to_string());