diff --git a/.github/workflows/openai-review.yml b/.github/workflows/openai-review.yml new file mode 100644 index 000000000..07fd8a681 --- /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/Cargo.lock b/Cargo.lock index 141bce739..eda85fc42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12154,6 +12154,7 @@ dependencies = [ "solana-packet", "solana-program-option 3.1.0", "solana-program-pack 3.1.0", + "solana-program-runtime", "solana-pubkey 3.0.0", "solana-pubsub-client", "solana-rpc-client", @@ -12218,6 +12219,8 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "solana-account 4.3.1", + "solana-commitment-config", "solana-keypair", "solana-pubkey 3.0.0", "solana-signer", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 53f510da0..36631ffb7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -115,6 +115,8 @@ axum = { version = "0.8", default-features = false, features = ["tokio", "http1" [dev-dependencies] ed25519-dalek = "1.0.1" +# Only the GoonFi live suite uses it, to host the CPI wrapper that drives the deployed program. +solana-program-runtime = "4.1.2" libsecp256k1 = "0.7.2" p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } test-case = { workspace = true } diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 70e080af2..695ce09a9 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 000000000..14ad284f7 --- /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 000000000..0ead16eb1 --- /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/protocols/goonfi/README.md b/crates/core/src/scenarios/protocols/goonfi/README.md new file mode 100644 index 000000000..74a7a134b --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/README.md @@ -0,0 +1,139 @@ +# GoonFi + +GoonFi V2 uses raw account layouts rather than an IDL. Each market points to a 32-byte +oracle owned by a companion publisher program. The oracle stores bid/ask prices; the +market stores the reference prices that guard them. Surfpool prepares these accounts +before a user runs a strategy. Product scenarios do not construct or submit swaps. + +## Pinned deployment + +The live tests in `crates/core/src/tests/goonfi/mod.rs` check these ProgramData sizes, +deployment slots and ELF hashes before replaying the program: + +| | Trading program | Oracle publisher | +|---|---|---| +| Program | `goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE` | `dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu` | +| ProgramData | `124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s` | `7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z` | +| ProgramData bytes | 252,429 | 557 | +| Deployment slot | 438563879 | 404369628 | +| ELF SHA-256 | `73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e` | `0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695` | + +A deployment change requires revalidation. These are test pins, not an upgrade-monitoring +service or a claim that every future deployment has the same layout. + +## Layouts and templates + +A market is 2048 bytes with magic `30 bc 2f 35 34 58 32 9a` at offset 0. Its base/quote +mints are at offsets 80/112, vaults at 144/176, and oracle pointer at 208. The oracle is +32 bytes with no discriminator. Both YAML layouts declare their expected program owner; +the shared materializer checks ownership before writing, then validates size, optional +magic bytes and write bounds. A failed owner check skips the override with a warning. + +| Template | Account | Fields | +|---|---|---| +| `goonfi-price` | Oracle | Bid and ask, u64 at offsets 0 and 8 | +| `goonfi-stale-quote` | Oracle | Freshness slot, u32 at offset 16; default lead -2000 | +| `goonfi-freshness` | Oracle | Freshness slot, u32 at offset 16; default lead 0 | +| `goonfi-reference-band` | Market | Reference prices, u64 at offsets 1712 and 1720 | + +Prices use the human pair price multiplied by `10^6`, independent of mint decimals. +For example, 99.74 quote tokens per base token becomes the integer string `"99740000"`. +Use strings for u64 price values to preserve precision in JSON and Studio. + +Slot templates write exactly four bytes. The u32 multiplier at offset 20 and the +millisecond timestamp at offset 24 remain untouched. A slot value of `null` selects the +template's default lead; an integer specifies a lead relative to the materialization +slot. The resulting slot must fit u32. + +## Catalog and price scenario + +The backend exposes three GoonFi MCP tools: + +- `list_goonfi_markets` discovers program accounts and validates market, oracle and mint + relationships. It returns market/oracle addresses, labels, mint addresses and decimals. + The YAML files contain no market catalog, and discovery does not require a fixed count. +- `create_goonfi_price_scenario` accepts a market address and a positive human price with + up to six decimal places. It resolves the oracle from the market account, validates + both accounts, and composes three overrides: equal oracle bid/ask, equal market reference + prices, and persistent freshness. An omitted market selects the default SOL/USDC market. +- `create_goonfi_liquidity_scenario` accepts a market address and per-vault remaining basis + points. It resolves both token vaults from the market's own pointers (offsets 144 and + 176), reads each current balance, validates the vault and oracle owners, and scales each + vault through `spl-token-account-balance`: 0 drains a vault so a swap rejects with `0x1`, + 10000 leaves it unchanged. A persistent freshness override keeps the rejection about + liquidity rather than a stale quote. Both default to 0; an omitted market selects the + default SOL/USDC market. + +These tools accept optional `surfnet_port`, defaulting to 8899, and read through the local +Surfnet RPC. Missing accounts fall back to that Surfnet's datasource. The price tool +stages through the shared Studio scenario API; Play registers the scenario. + +Studio's PMM fair-value dialog selects a protocol, a live market and a human price. It +calls these tools through Studio MCP without forwarding `rpcUrl` or `surfnet_port`, +matching the Tessera dialog convention. Consequently, these Studio GoonFi calls use the +backend's default RPC port. Studio retains only each catalog entry's market address and +label; the backend resolves the oracle when creating a price scenario. + +The price builder does not set `fetchBeforeUse`: the accounts read at creation retain +local edits, and only the specified fields are changed. Freshness uses `persist: true` +to stamp each subsequent materialization slot. These settings do not establish +transactional atomicity across all overrides in a scenario. + +## Composing other prepared states + +The four templates remain available through the generic scenario editor and AI flow. +There are no dedicated GoonFi spread or delayed-event builders. + +For a stale quote, target the oracle returned by `list_goonfi_markets` with +`goonfi-stale-quote`. Do not run a persistent freshness override over the same interval: +it would erase the stale state. Recovery can use `goonfi-freshness` at a later relative +slot. The Studio AI chip requests a stale-quote scenario through this generic flow. + +For depletion, `create_goonfi_liquidity_scenario` resolves the vaults from the market and +scales each balance for you; the AI chip calls it directly. Composing the same by hand +means reading the selected vault address from market offset 144 or 176, checking its token +program, and using `spl-token-account-balance` with an absolute amount, applied once. The +override does not recalculate percentages at execution time. + +## Behavioral verification + +The live suite fetches deployed account data and runs the pinned trading ELF in LiteSVM, +using a builtin wrapper for the Jupiter-shaped CPI. It checks: + +- Unchanged encoding produces the same fill; coupled price/reference changes alter output. +- Raising only the bid or lowering only the ask rejects with `0x24` (reference-band guard). +- Quotes decay with slot age and eventually reject with `0x15`. Changing the multiplier + changes decay in the tested fixture; stamping the slot restores freshness. Changing + the wall-clock timestamp alone does not change the tested fill. +- An impossible minimum output rejects with `0xf`. +- A successful sell still fills with exactly enough quote inventory. One atomic unit less + or an empty quote vault rejects with `0x1`, with the trade input held constant. +- The price builder's three overrides register and materialize through the production + path on two markets, preserving unrelated bytes and refreshing the u32 slot afterwards. +- Live discovery returns valid market/oracle relationships without a fixed catalog count. + +Behavior fixtures fund local vaults to at least 10,000 whole tokens and retain wrapped SOL +backing. This isolates price, ageing and inventory changes from fluctuating live liquidity; +it does not prove that the same trade currently has sufficient mainnet liquidity. Layout +and discovery checks use unfunded fetched accounts. Owner-predicate unit tests live in +`crates/types/src/scenarios.rs`. This suite does not provide a `pmm-sim` differential run +or a Studio browser test. + +Run all GoonFi unit and live checks serially: + +```bash +SURFPOOL_TEST_RPC_URL= cargo test -p surfpool-core --features integration-tests \ + goonfi -- --test-threads=1 --nocapture +``` + +The RPC variable is optional and defaults to the public mainnet endpoint. A private endpoint +can avoid public RPC rate limits. Re-run after a program upgrade or account-layout change. + +## Known boundaries + +The staleness window's on-chain source and exact decay formula remain unidentified. +Observed windows vary by market and time; historical slot ages are not fixed protocol +limits. The global account and other market fields are forked without assigned override +semantics. No enable/disable field is exposed. Direct top-level swaps are not covered by +the CPI replay, and the exact tolerance of the reference-band guard is not established +by these tests. diff --git a/crates/core/src/scenarios/protocols/goonfi/mod.rs b/crates/core/src/scenarios/protocols/goonfi/mod.rs new file mode 100644 index 000000000..a3a6d96c3 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/mod.rs @@ -0,0 +1 @@ +pub mod v1; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs new file mode 100644 index 000000000..c02fdae3a --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/liquidity.rs @@ -0,0 +1,467 @@ +//! GoonFi liquidity state preparation. +//! +//! A market draws liquidity from two SPL token vaults whose addresses live in the market account +//! at fixed offsets. Unlike price or depth, the balances are not in the protocol account itself but +//! in those separate token accounts, so this scales each vault through the generic +//! `spl-token-account-balance` template. Draining a vault to zero makes the deployed program reject +//! a swap with custom error 0x1; a fresh re-stamp keeps that rejection about liquidity and not a +//! stale quote. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, + types::TokenAccount, +}; + +use super::{ + GoonfiMarket, market_label, validate_goonfi_market_layout, validate_goonfi_oracle_layout, +}; + +/// Read, never written, so no template declares them. +const BASE_MINT_OFFSET: usize = 80; +const QUOTE_MINT_OFFSET: usize = 112; +const BASE_VAULT_OFFSET: usize = 144; +const QUOTE_VAULT_OFFSET: usize = 176; + +const LIQUIDITY_TEMPLATE: &str = "spl-token-account-balance"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Both overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// 10000 basis points leaves a vault untouched; 0 drains it. +const FULL_BPS: u16 = 10_000; + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiLiquidityPreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub base_vault: Pubkey, + pub quote_vault: Pubkey, + pub base_amount: u64, + pub quote_amount: u64, +} + +/// The two SPL token vaults a market draws liquidity from, read from the market's own pointers. +/// +/// Validates the market first: the shared raw-layout guard has no owner predicate, so the owner +/// check in `validate_goonfi_market_layout` is what keeps these offsets pointed at a real market. +pub fn vault_addresses(market_account: &Account) -> SurfpoolResult<[Pubkey; 2]> { + validate_goonfi_market_layout(market_account)?; + let base = read_pubkey(&market_account.data, BASE_VAULT_OFFSET)?; + let quote = read_pubkey(&market_account.data, QUOTE_VAULT_OFFSET)?; + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(invalid("market carries invalid vault pointers")); + } + Ok([base, quote]) +} + +/// Scales each vault balance to the requested basis points and keeps the quote fresh. +/// +/// `market_account` is the source of truth for the vault and oracle addresses; the three passed +/// accounts are the base vault, quote vault and oracle the caller fetched by those addresses, in +/// that order. A side left at 10000 bps is untouched and gets no override. +pub fn build_goonfi_liquidity_scenario( + market: Pubkey, + market_account: &Account, + base_vault_account: &Account, + quote_vault_account: &Account, + oracle_account: &Account, + base_remaining_bps: u16, + quote_remaining_bps: u16, +) -> SurfpoolResult { + if [base_remaining_bps, quote_remaining_bps] + .iter() + .any(|bps| *bps > FULL_BPS) + { + return Err(invalid( + "remaining liquidity must be 0..=10000 basis points; 0 drains a vault, 10000 leaves it unchanged", + )); + } + if base_remaining_bps == FULL_BPS && quote_remaining_bps == FULL_BPS { + return Err(invalid( + "both vaults left unchanged; set a lower basis point value to drain at least one side", + )); + } + + let [base_vault, quote_vault] = vault_addresses(market_account)?; + let oracle = GoonfiMarket::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + + let base_mint = read_pubkey(&market_account.data, BASE_MINT_OFFSET)?; + let quote_mint = read_pubkey(&market_account.data, QUOTE_MINT_OFFSET)?; + let base_amount = vault_amount(base_vault_account, "base", &base_mint)?; + let quote_amount = vault_amount(quote_vault_account, "quote", "e_mint)?; + let label = market_label(&base_mint, "e_mint); + + let registry = TemplateRegistry::new(); + let liquidity = template(®istry, LIQUIDITY_TEMPLATE)?; + + let mut scenario = Scenario::new( + format!("GoonFi {label} liquidity drain"), + format!( + "Prepare GoonFi {label} market ({market}) vaults to {} of base and {} of quote liquidity; no swap is sent.", + remaining_label(base_remaining_bps), + remaining_label(quote_remaining_bps) + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "liquidity-drain".to_string(), + ]; + + for (side, vault, current, bps) in [ + ("base", base_vault, base_amount, base_remaining_bps), + ("quote", quote_vault, quote_amount, quote_remaining_bps), + ] { + if bps == FULL_BPS { + continue; + } + let scaled = (u128::from(current) * u128::from(bps) / u128::from(FULL_BPS)) as u64; + scenario.add_override( + OverrideInstance::new( + liquidity.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(vault.to_string()), + ) + .with_values(HashMap::from([( + "amount".to_string(), + serde_json::json!(scaled.to_string()), + )])) + .with_label(format!("Drain GoonFi {side} vault")), + ); + } + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null keeps the + // template's own lead of zero. Persisted so the quote stays inside the staleness window and the + // swap the drained state is proven against is rejected for liquidity (0x1), not a stale quote. + scenario.add_override( + OverrideInstance::new( + FRESHNESS_TEMPLATE.to_string(), + PREPARATION_SLOT, + AccountAddress::Pubkey(oracle.to_string()), + ) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true), + ); + + Ok(GoonfiLiquidityPreparation { + scenario, + market, + base_vault, + quote_vault, + base_amount, + quote_amount, + }) +} + +/// Reads a vault balance, proving first that the account really is that market's token vault. +/// +/// An owner-and-length check is not enough: a mint is also owned by the token program and is long +/// enough to read an amount out of, so it would pass and its bytes would be misread as a balance. +/// Unpacking rejects anything that is not a token account, and the mint comparison ties the vault +/// to the side of the market it is supposed to hold. +fn vault_amount(account: &Account, side: &str, expected_mint: &Pubkey) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(invalid(format!( + "{side} vault is not owned by a supported token program" + ))); + } + let vault = TokenAccount::unpack(&account.data) + .map_err(|error| invalid(format!("{side} vault is not a token account: {error}")))?; + if vault.mint() != *expected_mint { + return Err(invalid(format!( + "{side} vault holds mint {} but the market's {side} mint is {expected_mint}", + vault.mint() + ))); + } + Ok(vault.amount()) +} + +fn remaining_label(bps: u16) -> String { + format!("{}.{:02}%", bps / 100, bps % 100) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data + .get(offset..offset + 32) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| invalid("market vault bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenarios::protocols::goonfi::v1::{GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID}; + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + const WSOL: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + const USDC: Pubkey = Pubkey::from_str_const("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + + fn market_account(base_vault: &Pubkey, quote_vault: &Pubkey) -> Account { + let mut data = vec![0u8; 2048]; + // Magic tag every live market shares. + data[0..8].copy_from_slice(&[48, 188, 47, 53, 52, 88, 50, 154]); + data[BASE_MINT_OFFSET..BASE_MINT_OFFSET + 32].copy_from_slice(WSOL.as_ref()); + data[QUOTE_MINT_OFFSET..QUOTE_MINT_OFFSET + 32].copy_from_slice(USDC.as_ref()); + data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].copy_from_slice(base_vault.as_ref()); + data[QUOTE_VAULT_OFFSET..QUOTE_VAULT_OFFSET + 32].copy_from_slice(quote_vault.as_ref()); + data[208..240].copy_from_slice(FIXTURE_ORACLE.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn vault(mint: &Pubkey, amount: u64) -> Account { + const AMOUNT_OFFSET: usize = 64; + const STATE_OFFSET: usize = 108; + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(Pubkey::new_unique().as_ref()); + data[AMOUNT_OFFSET..AMOUNT_OFFSET + 8].copy_from_slice(&amount.to_le_bytes()); + data[STATE_OFFSET] = 1; + Account { + data, + owner: spl_token_interface::ID, + ..Account::default() + } + } + + fn oracle() -> Account { + Account { + data: vec![0u8; 32], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + #[test] + fn drains_both_vaults_and_keeps_the_quote_fresh() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let market = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + market, + &market_account(&base_vault, "e_vault), + &vault(&WSOL, 2_441_078_070_812), + &vault(&USDC, 216_136_231_615), + &oracle(), + 0, + 0, + ) + .unwrap(); + + assert_eq!(preparation.base_vault, base_vault); + assert_eq!(preparation.quote_vault, quote_vault); + // A friendly pair label, not the raw market pubkey. + assert_eq!(preparation.scenario.name, "GoonFi SOL/USDC liquidity drain"); + let [base, quote, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected base drain, quote drain and freshness overrides"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + assert_eq!( + quote.account, + AccountAddress::Pubkey(quote_vault.to_string()) + ); + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("0"))); + assert_eq!(quote.values.get("amount"), Some(&serde_json::json!("0"))); + assert!(!base.fetch_before_use); + assert!(!base.persist); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(FIXTURE_ORACLE.to_string()) + ); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_partially_and_skips_an_unchanged_side() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let preparation = build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market_account(&base_vault, "e_vault), + &vault(&WSOL, 1_000), + &vault(&USDC, 999), + &oracle(), + 2_500, + FULL_BPS, + ) + .unwrap(); + + let [base, freshness] = &preparation.scenario.overrides[..] else { + panic!("the unchanged quote side must not get an override"); + }; + assert_eq!(base.account, AccountAddress::Pubkey(base_vault.to_string())); + // 1000 * 2500 / 10000, exact integer arithmetic. + assert_eq!(base.values.get("amount"), Some(&serde_json::json!("250"))); + assert_eq!(freshness.values.len(), 1); + } + + #[test] + fn rejects_bad_basis_points_and_accounts() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let good_market = market_account(&base_vault, "e_vault); + + // Out of range and a no-op leave nothing to prepare. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(&WSOL, 1), + &vault(&USDC, 1), + &oracle(), + 10_001, + 0 + ) + .is_err() + ); + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(&WSOL, 1), + &vault(&USDC, 1), + &oracle(), + FULL_BPS, + FULL_BPS + ) + .is_err() + ); + + // A foreign account of the same size passes the raw guard, so the owner check must reject. + let foreign_market = Account { + owner: Pubkey::new_unique(), + ..market_account(&base_vault, "e_vault) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &foreign_market, + &vault(&WSOL, 1), + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A vault not owned by a token program is not a real vault. + let foreign_vault = Account { + owner: Pubkey::new_unique(), + ..vault(&WSOL, 1) + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &foreign_vault, + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A foreign oracle carries no magic, so its owner is the only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &good_market, + &vault(&WSOL, 1), + &vault(&USDC, 1), + &foreign_oracle, + 0, + 0 + ) + .is_err() + ); + } + + /// An owner-and-length check would pass a mint: it is token-program-owned and long enough to + /// misread an amount out of. Unpacking plus the mint comparison is what rejects it. + #[test] + fn rejects_a_vault_that_is_not_this_markets_token_account() { + let market = market_account(&Pubkey::new_unique(), &Pubkey::new_unique()); + let mint_account = Account { + data: vec![0u8; 82], + owner: spl_token_interface::ID, + ..Account::default() + }; + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &mint_account, + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + + // A real token account holding the other side's mint is refused as well. + assert!( + build_goonfi_liquidity_scenario( + Pubkey::new_unique(), + &market, + &vault(&USDC, 1), + &vault(&USDC, 1), + &oracle(), + 0, + 0 + ) + .is_err() + ); + } + + #[test] + fn resolves_vault_addresses_from_the_market() { + let base_vault = Pubkey::new_unique(); + let quote_vault = Pubkey::new_unique(); + let [base, quote] = vault_addresses(&market_account(&base_vault, "e_vault)).unwrap(); + assert_eq!(base, base_vault); + assert_eq!(quote, quote_vault); + + let mut zero_pointer = market_account(&base_vault, "e_vault); + zero_pointer.data[BASE_VAULT_OFFSET..BASE_VAULT_OFFSET + 32].fill(0); + assert!(vault_addresses(&zero_pointer).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml new file mode 100644 index 000000000..6a218ccfa --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/market_overrides.yaml @@ -0,0 +1,55 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: MarketState + +# The write target here is the market account itself: 2048 bytes owned by the GoonFi program, +# tagged by the 8 magic bytes every live market shares. Mints, vaults and the oracle pointer live +# in cleartext at fixed offsets; the only fields a product flow writes are the two reference +# prices the deployed program uses as an anti-manipulation band around the oracle. +raw_layout: + account_size: 2048 + owner: goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE + magic: + offset: 0 + bytes: [48, 188, 47, 53, 52, 88, 50, 154] + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-reference-band + name: Override GoonFi Reference Band + description: Move the market's reference prices that band-guard the oracle + idl_account_name: MarketState + address: + type: pubkey + value: GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC + properties: + - path: reference_price_a_x1e6 + offset: 1712 + encoding: u64 + label: Reference price A + description: "First reference anchor, human pair price times 10^6. Same scale as the oracle's bid and ask." + - path: reference_price_b_x1e6 + offset: 1720 + encoding: u64 + label: Reference price B + description: "Second reference anchor, human pair price times 10^6. The pair's order is not fixed; scale both by the same factor." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, scaled by the same factor as the oracle price move they + accompany. The deployed program rejects a swap with custom error 0x24 when the oracle + price it is about to use falls outside the band these two anchors define, in the + direction unfavorable to the venue: a raised bid blocks sells, a lowered ask blocks buys. + + This template exists as the second half of goonfi-price: apply both to shift a market's + price beyond a fraction of a percent. Use the market address returned by + list_goonfi_markets; its oracle field identifies the paired price account. The + GoonFi price builder composes the pair (plus freshness) automatically; composing by hand + and skipping either account breaks the invariant with error 0x24. + + Set fetchBeforeUse: true so the live market is forked before your reference prices apply; on + a fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs new file mode 100644 index 000000000..b8c71c728 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/markets.rs @@ -0,0 +1,302 @@ +use std::collections::HashMap; + +use solana_account::Account; +use solana_account_decoder::UiAccountEncoding; +use solana_client::{ + rpc_config::RpcAccountInfoConfig, + rpc_filter::{Memcmp, RpcFilterType}, +}; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::VERIFIED_TOKENS_BY_SYMBOL; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, + surfnet::remote::SurfnetRemoteClient, + types::MintAccount, +}; + +use super::{GOONFI_DEFAULT_MARKET, GOONFI_PROGRAM_ID, GoonfiMarket}; + +#[derive(Debug, PartialEq)] +pub struct GoonfiDiscoveredMarket { + pub address: Pubkey, + pub oracle: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub base_decimals: u8, + pub quote_decimals: u8, +} + +impl GoonfiDiscoveredMarket { + pub fn label(&self) -> String { + market_label(&self.base_mint, &self.quote_mint) + } +} + +/// A human pair label from the two mints, e.g. "SOL/USDC". Falls back to a mint's full address +/// when it is not in the verified token list, so an unknown pair is still uniquely named. +pub fn market_label(base_mint: &Pubkey, quote_mint: &Pubkey) -> String { + let symbol = |mint: &Pubkey| { + let address = mint.to_string(); + VERIFIED_TOKENS_BY_SYMBOL + .values() + .filter(|token| token.address == address) + .map(|token| token.symbol.as_str()) + .min() + .map(str::to_string) + .unwrap_or(address) + }; + format!("{}/{}", symbol(base_mint), symbol(quote_mint)) +} + +fn market_references(account: &Account) -> SurfpoolResult<[Pubkey; 3]> { + let oracle = GoonfiMarket::oracle_address(account)?; + let base = Pubkey::new_from_array(account.data[80..112].try_into().unwrap()); + let quote = Pubkey::new_from_array(account.data[112..144].try_into().unwrap()); + if base == Pubkey::default() || quote == Pubkey::default() || base == quote { + return Err(SurfpoolError::internal( + "GoonFi market has invalid mint identities", + )); + } + Ok([base, quote, oracle]) +} + +fn mint_decimals(account: &Account) -> SurfpoolResult { + if account.owner != spl_token_interface::ID && account.owner != spl_token_2022_interface::ID { + return Err(SurfpoolError::internal( + "GoonFi mint is not owned by a supported token program", + )); + } + Ok(MintAccount::unpack(&account.data)?.decimals()) +} + +fn resolve_market( + address: Pubkey, + account: &Account, + references: &HashMap, +) -> SurfpoolResult { + let [base, quote, oracle] = market_references(account)?; + let required = |address: &Pubkey| { + references.get(address).ok_or_else(|| { + SurfpoolError::internal(format!("GoonFi referenced account {address} was not found")) + }) + }; + GoonfiMarket::validate(address, account, required(&oracle)?)?; + Ok(GoonfiDiscoveredMarket { + address, + oracle, + base_mint: base, + quote_mint: quote, + base_decimals: mint_decimals(required(&base)?)?, + quote_decimals: mint_decimals(required("e)?)?, + }) +} + +pub async fn discover_goonfi_markets( + client: &SurfnetRemoteClient, +) -> SurfpoolResult> { + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .and_then(|template| template.raw_layout.as_ref()) + .ok_or_else(|| SurfpoolError::internal("GoonFi market layout is unavailable"))?; + let mut filters = vec![RpcFilterType::DataSize(layout.account_size as u64)]; + if let Some(magic) = &layout.magic { + filters.push(RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + magic.offset, + magic.bytes.clone(), + ))); + } + let accounts = client + .get_program_accounts( + &GOONFI_PROGRAM_ID, + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + commitment: Some(CommitmentConfig::confirmed()), + ..Default::default() + }, + Some(filters), + ) + .await? + .into_result()?; + // One obsolete or malformed market must not hide every valid one, so a market that fails + // validation is skipped with a warning and the rest of the catalog is still returned. This is + // the same warn-and-continue rule the materializer applies per override. + let candidates = accounts.len(); + let mut retained = Vec::new(); + let mut addresses = Vec::new(); + for (address, encoded) in accounts { + let Some(account) = encoded.to_account() else { + warn!("Skipping GoonFi market {address}: its account data could not be decoded"); + continue; + }; + match market_references(&account) { + Ok(references) => { + addresses.extend(references); + retained.push((address, account)); + } + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } + } + addresses.sort_unstable(); + addresses.dedup(); + let mut references = HashMap::new(); + for batch in addresses.chunks(100) { + let fetched = client + .get_multiple_accounts(batch, CommitmentConfig::confirmed()) + .await?; + for (address, account) in batch.iter().zip(fetched) { + // A reference the fork cannot serve disqualifies only the markets pointing at it, + // which `resolve_market` reports below. + if let Ok(account) = account.map_account() { + references.insert(*address, account); + } + } + } + let mut markets = Vec::new(); + for (address, account) in &retained { + match resolve_market(*address, account, &references) { + Ok(market) => markets.push(market), + Err(error) => warn!("Skipping GoonFi market {address}: {error}"), + } + } + // An empty catalog from a program that does own markets is a failure, not a partial result. + if markets.is_empty() && candidates > 0 { + return Err(SurfpoolError::internal(format!( + "none of the {candidates} discovered GoonFi markets validated; the integration needs a refresh" + ))); + } + markets.sort_by_cached_key(|market| { + ( + market.address != GOONFI_DEFAULT_MARKET, + market.label(), + market.address, + ) + }); + Ok(markets) +} + +#[cfg(test)] +mod tests { + use solana_program_pack::Pack; + + use super::*; + use crate::scenarios::protocols::goonfi::v1::GOONFI_ORACLE_PROGRAM_ID; + + fn fixture() -> (Pubkey, Account, HashMap) { + let address = Pubkey::new_unique(); + let base = Pubkey::new_unique(); + let quote = Pubkey::new_unique(); + let oracle = Pubkey::new_unique(); + let registry = TemplateRegistry::new(); + let layout = registry + .get("goonfi-reference-band") + .unwrap() + .raw_layout + .as_ref() + .unwrap(); + let mut market = Account { + owner: GOONFI_PROGRAM_ID, + data: vec![0; layout.account_size], + ..Account::default() + }; + let magic = layout.magic.as_ref().unwrap(); + market.data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + market.data[80..112].copy_from_slice(base.as_ref()); + market.data[112..144].copy_from_slice(quote.as_ref()); + market.data[208..240].copy_from_slice(oracle.as_ref()); + let mint = |decimals| { + let mut account = Account { + owner: spl_token_interface::ID, + data: vec![0; spl_token_interface::state::Mint::LEN], + ..Account::default() + }; + spl_token_interface::state::Mint { + decimals, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut account.data); + account + }; + ( + address, + market, + HashMap::from([ + (base, mint(9)), + (quote, mint(6)), + ( + oracle, + Account { + owner: GOONFI_ORACLE_PROGRAM_ID, + data: vec![0; 32], + ..Account::default() + }, + ), + ]), + ) + } + + #[test] + fn goonfi_discovery_accepts_uncataloged_markets_and_preserves_mint_identity() { + let (address, account, references) = fixture(); + let result = resolve_market(address, &account, &references).unwrap(); + assert_eq!(result.address, address); + assert_eq!((result.base_decimals, result.quote_decimals), (9, 6)); + assert_eq!( + result.label(), + format!("{}/{}", result.base_mint, result.quote_mint) + ); + assert_eq!(result.oracle, market_references(&account).unwrap()[2]); + } + + #[test] + fn goonfi_discovery_rejects_invalid_market_layouts_and_mint_identities() { + let (_, account, _) = fixture(); + for invalid in 0..5 { + let mut account = account.clone(); + match invalid { + 0 => account.owner = Pubkey::new_unique(), + 1 => { + account.data.pop(); + } + 2 => account.data[0] ^= 1, + 3 => account.data[80..112].fill(0), + _ => { + let base = account.data[80..112].to_vec(); + account.data[112..144].copy_from_slice(&base); + } + } + assert!( + market_references(&account).is_err(), + "invalid case {invalid}" + ); + } + } + + #[test] + fn goonfi_discovery_rejects_missing_or_invalid_referenced_accounts() { + let (address, account, references) = fixture(); + let [base, _, oracle] = market_references(&account).unwrap(); + for invalid in 0..5 { + let mut references = references.clone(); + match invalid { + 0 => { + references.remove(&oracle); + } + 1 => references.get_mut(&oracle).unwrap().owner = Pubkey::new_unique(), + 2 => { + references.get_mut(&oracle).unwrap().data.pop(); + } + 3 => references.get_mut(&base).unwrap().owner = Pubkey::new_unique(), + _ => references.get_mut(&base).unwrap().data.fill(0), + } + assert!( + resolve_market(address, &account, &references).is_err(), + "invalid case {invalid}" + ); + } + } +} diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs new file mode 100644 index 000000000..b0852c772 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/mod.rs @@ -0,0 +1,13 @@ +mod liquidity; +mod markets; +mod price; + +pub use liquidity::{GoonfiLiquidityPreparation, build_goonfi_liquidity_scenario, vault_addresses}; + +pub use price::{ + GOONFI_DEFAULT_MARKET, GOONFI_ORACLE_PROGRAM_ID, GOONFI_PROGRAM_ID, GoonfiMarket, + GoonfiPricePreparation, build_goonfi_price_scenario, validate_goonfi_market_layout, + validate_goonfi_oracle_layout, +}; + +pub use markets::{GoonfiDiscoveredMarket, discover_goonfi_markets, market_label}; diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml new file mode 100644 index 000000000..70ca05004 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/oracle_overrides.yaml @@ -0,0 +1,130 @@ +protocol: GoonFi +version: deployed-438563879 +account_type: PriceOracle + +# The write target of every template here is the market's price-oracle account: 32 bytes owned by +# the companion publisher program. It carries no discriminator, so the byte guard can only pin +# the size; the owner predicate below is what keeps a raw write out of a foreign 32-byte account, +# and the builder additionally resolves the oracle through the market account's own pointer. +raw_layout: + account_size: 32 + owner: dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu + +tags: + - pmm + - prop-amm + - swap + +templates: + - id: goonfi-price + name: Override GoonFi Price + description: Move a GoonFi market's oracle bid and ask atomically in both directions + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: bid_price_x1e6 + offset: 0 + encoding: u64 + label: Bid price + description: "The human pair price the venue buys base at, multiplied by 10^6. Independent of mint decimals." + - path: ask_price_x1e6 + offset: 8 + encoding: u64 + label: Ask price + description: "The human pair price the venue sells base at, multiplied by 10^6. Live oracles publish it at or above the bid; keep that shape." + llm_context: | + SET BOTH FIELDS AS ONE INVARIANT, with ask >= bid. Values are the human pair price times + 10^6 regardless of mint decimals: SOL at 99.74 USDC is bid_price_x1e6 "99740000". Use + decimal integer strings, not JSON numbers. + + THE PRICE IS BAND-GUARDED ACROSS TWO ACCOUNTS. The deployed program rejects a swap with + custom error 0x24 when the oracle price sits outside the reference band stored in the + market account - a decoupled move of even 5% is proven to reject, and the live oracle + tracks its band within a fraction of a percent. Always apply goonfi-reference-band to the + market address returned by list_goonfi_markets, scaled by the same factor. The GoonFi price + builder composes both overrides plus freshness automatically; composing the raw templates + by hand and skipping one of them breaks the invariant. + + Set fetchBeforeUse: true so the live oracle is forked before your bid and ask apply; on a + fresh fork the account is not local yet, and an override on a missing account is skipped. + Use false only for a later override that builds on state an earlier override prepared in the + same scenario. + + GoonFi rejects a quote whose oracle is past its staleness window with custom error 0x15. + Pair long-running scenarios with goonfi-freshness. + + - id: goonfi-stale-quote + name: Make GoonFi Quote Stale + description: Age a GoonFi oracle past its rejection window + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: -2000 + width: 4 + label: Slot lead + description: >- + How far behind the materialization slot to place the oracle's update slot, as a + negative integer. Pass null to use -2000, past every observed window including the + stablecoin tier's. + llm_context: | + The value you pass IS the lead: Surfpool writes the materialization slot plus it, clamped + at zero. Pass null to take the -2000 default. The slot field is 4 bytes; the dynamic + multiplier stored beside it stays untouched. + + The rejection window is per-market and publisher-adjustable: ages 16 and 21 were observed + on volatile pairs and windows of a few hundred slots on stablecoin pairs, all on one day. + These observations are not fixed limits. Inside the window the deployed program decays + the quote with age - + faster the higher the oracle's multiplier at offset 20 - before rejecting outright with + custom error 0x15, so a small negative lead prepares a degraded-but-fillable quote and + the -2000 default prepares a rejected one on every observed market. + + Do not persist this override: the quote should stay stale. For a standalone stale quote on a + fresh fork, set fetchBeforeUse: true so the live oracle is forked before the ageing applies; + an override on an account not yet local is skipped. In a lifecycle where an earlier + goonfi-freshness override already forked and edited the oracle locally, use false so this + override does not refetch remote bytes over that local edit: refresh once at slot zero, age + at the requested relative slot, then optionally refresh with persist at a later recovery + slot. The initial refresh must not persist or it will erase the stale event. Before a + delayed event, the initial quote naturally ages. Keep override labels short ("SOL/USDC stale + quote"). + + - id: goonfi-freshness + name: Refresh GoonFi Quote + description: Publish the materialization slot into the oracle's freshness field + idl_account_name: PriceOracle + address: + type: pubkey + value: 7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3 + properties: + - path: last_update_slot + offset: 16 + encoding: + slot: + lead: 0 + width: 4 + label: Current materialization slot + description: Slot lead, as an integer. Pass null to take the lead of zero and write the materialization slot itself. + llm_context: | + GoonFi rejects a swap whose oracle has aged past its per-market window with custom error + 0x15 - a loud failure, unlike BisonFi's silent zero - and decays the quote with age before + that, at a rate proportional to the oracle's multiplier at offset 20. Re-stamping this + field alone restores the full quote; the market account's own slot fields do not gate + freshness. + + Pass null for last_update_slot to take this template's lead of zero, which writes the + exact materialization slot. A number would be read as the lead instead. Use persist: true + when the prepared state must remain executable beyond the window; each application then + writes its own slot. + + Set fetchBeforeUse: true when this is the first override to touch the oracle on a fresh fork, + so the live account is forked before the stamp; an override on an account not yet local is + skipped. Use false when an earlier override in the same scenario already forked it. diff --git a/crates/core/src/scenarios/protocols/goonfi/v1/price.rs b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs new file mode 100644 index 000000000..d005c9703 --- /dev/null +++ b/crates/core/src/scenarios/protocols/goonfi/v1/price.rs @@ -0,0 +1,453 @@ +//! GoonFi price state preparation. +//! +//! GoonFi publishes no IDL. Every write goes through the raw layouts in `oracle_overrides.yaml` +//! and `market_overrides.yaml`; this module exists for what those templates cannot express: the +//! price lives in a per-market oracle account that must be resolved from the market's own pointer +//! and validated by owner, and a price move is one invariant across two accounts - oracle bid and +//! ask, the market's reference band, and a freshness re-stamp. + +use std::{collections::HashMap, sync::LazyLock}; + +use solana_account::Account; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, OverrideTemplate, RawLayout, Scenario}; + +use crate::{ + error::{SurfpoolError, SurfpoolResult}, + scenarios::TemplateRegistry, +}; + +pub const GOONFI_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"); +/// The companion publisher program that owns every market's price oracle. +pub const GOONFI_ORACLE_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"); +pub const GOONFI_DEFAULT_MARKET: Pubkey = + Pubkey::from_str_const("GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC"); + +/// Read, never written, so no template declares it. +const ORACLE_POINTER_OFFSET: usize = 208; + +/// The layouts a GoonFi market and its oracle must have, taken from the manifests the raw +/// templates are written against so there is one definition of them. Built once; both manifests +/// are compiled in. +static ORACLE_LAYOUT: LazyLock = LazyLock::new(|| layout_of(PRICE_TEMPLATE)); +static MARKET_LAYOUT: LazyLock = LazyLock::new(|| layout_of(REFERENCE_TEMPLATE)); + +fn layout_of(template_id: &str) -> RawLayout { + template(&TemplateRegistry::new(), template_id) + .and_then(|template| { + template + .raw_layout + .clone() + .ok_or_else(|| SurfpoolError::internal("the GoonFi manifests carry no raw layout")) + }) + .expect("the GoonFi manifests are compiled in and always parse") +} + +const PRICE_TEMPLATE: &str = "goonfi-price"; +const REFERENCE_TEMPLATE: &str = "goonfi-reference-band"; +const FRESHNESS_TEMPLATE: &str = "goonfi-freshness"; + +/// Prices are the human pair price times 10^6, independent of mint decimals. +const PRICE_SCALE_DECIMALS: u32 = 6; + +/// All three overrides apply on Play, before any slot advance. +const PREPARATION_SLOT: u64 = 0; + +/// The parts of a GoonFi market a price move needs: the market account itself and the oracle it +/// points at. +/// +/// The two are private so the pair can only be built through `validate`, which reads the oracle +/// from the market's own pointer. Public fields would let a caller assemble the pair from scratch +/// or re-point a validated one, aiming a price move at one market's reference band and an +/// unrelated market's oracle - a combination the deployed program rejects with 0x24 at best, and +/// silently misprices at worst. +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiMarket { + address: Pubkey, + oracle: Pubkey, +} + +impl GoonfiMarket { + /// The oracle the market prices from, read from the market's own pointer. Never trust a + /// caller-supplied oracle address: the oracle is 32 undiscriminated bytes, so the pointer + /// plus the owner check below are what keep a write out of a foreign account. + pub fn oracle_address(market_account: &Account) -> SurfpoolResult { + validate_goonfi_market_layout(market_account)?; + let oracle = read_pubkey(&market_account.data, ORACLE_POINTER_OFFSET)?; + if oracle == Pubkey::default() { + return Err(invalid("market carries no oracle pointer")); + } + Ok(oracle) + } + + pub fn validate( + address: Pubkey, + market_account: &Account, + oracle_account: &Account, + ) -> SurfpoolResult { + let oracle = Self::oracle_address(market_account)?; + validate_goonfi_oracle_layout(oracle_account)?; + Ok(Self { address, oracle }) + } +} + +/// Rejects an account that is not a GoonFi market. +/// +/// The shared raw-layout guard has no owner predicate, so a foreign account of the same size +/// carrying the same magic would pass it. Every builder-made scenario comes through here, which +/// adds the ownership check the schema cannot express. +pub fn validate_goonfi_market_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_PROGRAM_ID { + return Err(invalid("market is not owned by GoonFi")); + } + MARKET_LAYOUT.guard(&account.data).map_err(invalid) +} + +/// Rejects an account that is not a GoonFi price oracle. +/// +/// The oracle is 32 bytes with no magic at all, so its guard pins only the size; the owner check +/// here is the real discriminator. +pub fn validate_goonfi_oracle_layout(account: &Account) -> SurfpoolResult<()> { + if account.owner != GOONFI_ORACLE_PROGRAM_ID { + return Err(invalid("oracle is not owned by the GoonFi publisher")); + } + ORACLE_LAYOUT.guard(&account.data).map_err(invalid) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GoonfiPricePreparation { + pub scenario: Scenario, + pub market: Pubkey, + pub oracle: Pubkey, + pub price_x1e6: u64, +} + +pub fn build_goonfi_price_scenario( + market: &GoonfiMarket, + price: &str, +) -> SurfpoolResult { + let price_x1e6 = human_price_to_x1e6(price)?; + let scaled = price_x1e6.to_string(); + + let registry = TemplateRegistry::new(); + let price_template = template(®istry, PRICE_TEMPLATE)?; + let reference = template(®istry, REFERENCE_TEMPLATE)?; + let freshness = template(®istry, FRESHNESS_TEMPLATE)?; + let market_name = market.address.to_string(); + let oracle_target = AccountAddress::Pubkey(market.oracle.to_string()); + + // No fetch_before_use anywhere: the oracle and reference values are absolute targets for the + // account graph creation read, and a Play-time refetch would reinstall remote bytes over any + // local edit. + let price_override = OverrideInstance::new( + price_template.id.clone(), + PREPARATION_SLOT, + oracle_target.clone(), + ) + .with_values(HashMap::from([ + ( + "bid_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "ask_price_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ])) + .with_label(format!("GoonFi {market_name} price")); + + // The deployed program rejects an oracle price outside the market's reference band with + // custom error 0x24, so the band moves to the same target as one invariant. + let reference_override = OverrideInstance::new( + reference.id.clone(), + PREPARATION_SLOT, + AccountAddress::Pubkey(market.address.to_string()), + ) + .with_values(HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!(scaled.clone()), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!(scaled), + ), + ])) + .with_label(format!("GoonFi {market_name} reference band")); + + // Null, not zero: the slot encoder reads a supplied number AS the lead, so only null takes + // the template's own lead of zero. Persisted, so the prepared price stays inside the oracle's + // staleness window however long the scenario is left running. + let freshness_override = + OverrideInstance::new(freshness.id.clone(), PREPARATION_SLOT, oracle_target) + .with_values(HashMap::from([( + "last_update_slot".to_string(), + serde_json::Value::Null, + )])) + .with_label("Keep GoonFi quote fresh".to_string()) + .with_persist(true); + + let normalized_price = price.trim(); + let mut scenario = Scenario::new( + format!("GoonFi {market_name} at {normalized_price}"), + format!( + "Prepare GoonFi market {} to quote one base token at {normalized_price} quote tokens; no swap is sent.", + market.address + ), + ); + scenario.tags = vec![ + "goonfi".to_string(), + "pmm".to_string(), + "price-dislocation".to_string(), + ]; + scenario.add_override(price_override); + scenario.add_override(reference_override); + scenario.add_override(freshness_override); + + Ok(GoonfiPricePreparation { + scenario, + market: market.address, + oracle: market.oracle, + price_x1e6, + }) +} + +fn read_pubkey(data: &[u8], offset: usize) -> SurfpoolResult { + let bytes: [u8; 32] = data[offset..offset + 32] + .try_into() + .map_err(|_| invalid("market oracle bytes are truncated"))?; + Ok(Pubkey::new_from_array(bytes)) +} + +pub(super) fn human_price_to_x1e6(price: &str) -> SurfpoolResult { + let value = price.trim(); + let mut parts = value.split('.'); + let whole = parts.next().unwrap_or_default(); + let fractional = parts.next().unwrap_or_default(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fractional.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(invalid("price must be a positive decimal string")); + } + + // Reject rather than truncate: a seventh decimal place cannot be represented, and silently + // dropping it would prepare a different price than the caller asked for. + if fractional.len() > PRICE_SCALE_DECIMALS as usize { + return Err(invalid(format!( + "price carries more than {PRICE_SCALE_DECIMALS} decimal places, past GoonFi's 10^-6 resolution" + ))); + } + let digits = format!("{whole}{fractional}") + .parse::() + .map_err(|_| invalid("price is too large"))?; + let exponent = PRICE_SCALE_DECIMALS - fractional.len() as u32; + let scaled = 10u128 + .checked_pow(exponent) + .and_then(|power| digits.checked_mul(power)) + .ok_or_else(|| invalid("price is too large"))?; + if scaled == 0 { + return Err(invalid("price must be greater than zero")); + } + u64::try_from(scaled).map_err(|_| { + let max_price = u64::MAX / 10u64.pow(PRICE_SCALE_DECIMALS); + invalid(format!( + "price is too large for GoonFi's u64 field; a market accepts at most about {max_price} quote per base" + )) + }) +} + +fn template<'a>(registry: &'a TemplateRegistry, id: &str) -> SurfpoolResult<&'a OverrideTemplate> { + registry + .get(id) + .ok_or_else(|| SurfpoolError::internal(format!("GoonFi template {id} is unavailable"))) +} + +fn invalid(message: impl Into) -> SurfpoolError { + SurfpoolError::internal(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn market_account(oracle: &Pubkey) -> Account { + let mut data = vec![0; MARKET_LAYOUT.account_size]; + let magic = MARKET_LAYOUT.magic.as_ref().expect("manifest layout tag"); + data[magic.offset..magic.offset + magic.bytes.len()].copy_from_slice(&magic.bytes); + data[ORACLE_POINTER_OFFSET..ORACLE_POINTER_OFFSET + 32].copy_from_slice(oracle.as_ref()); + Account { + data, + owner: GOONFI_PROGRAM_ID, + ..Account::default() + } + } + + fn oracle_account() -> Account { + Account { + data: vec![0; ORACLE_LAYOUT.account_size], + owner: GOONFI_ORACLE_PROGRAM_ID, + ..Account::default() + } + } + + const FIXTURE_ORACLE: Pubkey = + Pubkey::from_str_const("7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3"); + + fn market() -> GoonfiMarket { + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&FIXTURE_ORACLE), + &oracle_account(), + ) + .expect("valid GoonFi market") + } + + #[test] + fn builds_price_scenario_across_both_accounts() { + let market = market(); + let preparation = build_goonfi_price_scenario(&market, "99.74").unwrap(); + assert_eq!(preparation.price_x1e6, 99_740_000); + + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected price, reference-band and freshness overrides"); + }; + assert_eq!( + price.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + reference.account, + AccountAddress::Pubkey(market.address.to_string()) + ); + assert_eq!( + freshness.account, + AccountAddress::Pubkey(market.oracle.to_string()) + ); + assert_eq!( + price.values.get("bid_price_x1e6"), + Some(&serde_json::json!("99740000")) + ); + assert_eq!( + reference.values.get("reference_price_b_x1e6"), + Some(&serde_json::json!("99740000")) + ); + } + + /// The values are absolute targets for the creation read, so nothing refetches at Play; the + /// freshness value must stay null because the slot encoder reads a supplied number as the + /// lead rather than ignoring it. + #[test] + fn price_stays_on_the_creation_read_and_freshness_keeps_the_template_lead() { + let preparation = build_goonfi_price_scenario(&market(), "1").unwrap(); + let [price, reference, freshness] = &preparation.scenario.overrides[..] else { + panic!("expected exactly three overrides"); + }; + assert!(!price.fetch_before_use); + assert!(!price.persist); + assert!(!reference.fetch_before_use); + assert!(!reference.persist); + assert!(!freshness.fetch_before_use); + assert!(freshness.persist); + assert_eq!( + freshness.values.get("last_update_slot"), + Some(&serde_json::Value::Null) + ); + } + + #[test] + fn scales_prices_by_ten_to_the_sixth_regardless_of_decimals() { + for (price, expected) in [ + ("77526.523154", 77_526_523_154u64), + ("0.00841", 8_410), + ("1558.9384", 1_558_938_400), + ] { + let preparation = build_goonfi_price_scenario(&market(), price).unwrap(); + assert_eq!(preparation.price_x1e6, expected, "price {price}"); + } + } + + #[test] + fn rejects_invalid_price_and_account_inputs() { + let market = market(); + for price in [ + "0", + "-1", + "1.2.3", + "not-a-price", + "", + "0.0000001", + "1.0000009", + ] { + assert!( + build_goonfi_price_scenario(&market, price).is_err(), + "price {price} must be refused" + ); + } + + // A pathological fraction must come back as an error, never a panic or a wrapped value. + let poison = format!("0.{}1", "0".repeat(133)); + assert!(build_goonfi_price_scenario(&market, &poison).is_err()); + let long_whole = "9".repeat(60); + assert!(build_goonfi_price_scenario(&market, &long_whole).is_err()); + + let uncataloged = GoonfiMarket { + address: Pubkey::new_unique(), + oracle: Pubkey::new_unique(), + }; + let preparation = build_goonfi_price_scenario(&uncataloged, "1").unwrap(); + assert!( + preparation + .scenario + .name + .contains(&uncataloged.address.to_string()) + ); + assert_eq!(preparation.oracle, uncataloged.oracle); + + let oracle = Pubkey::new_unique(); + let wrong_owner = Account { + owner: Pubkey::new_unique(), + ..market_account(&oracle) + }; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &wrong_owner, &oracle_account()).is_err() + ); + // The raw guard cannot see the owner, which is the whole reason this check sits on top. + assert!(MARKET_LAYOUT.guard(&wrong_owner.data).is_ok()); + + let mut bad_magic = market_account(&oracle); + bad_magic.data[0] ^= 0xff; + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &bad_magic, &oracle_account()).is_err() + ); + + let no_pointer = market_account(&Pubkey::default()); + assert!( + GoonfiMarket::validate(Pubkey::new_unique(), &no_pointer, &oracle_account()).is_err() + ); + + // The oracle carries no magic at all, so the owner check is its only discriminator. + let foreign_oracle = Account { + owner: Pubkey::new_unique(), + ..oracle_account() + }; + assert!( + GoonfiMarket::validate( + Pubkey::new_unique(), + &market_account(&oracle), + &foreign_oracle + ) + .is_err() + ); + assert!(ORACLE_LAYOUT.guard(&foreign_oracle.data).is_ok()); + + let truncated_oracle = Account { + data: vec![0; 16], + ..oracle_account() + }; + assert!(validate_goonfi_oracle_layout(&truncated_oracle).is_err()); + } +} diff --git a/crates/core/src/scenarios/protocols/mod.rs b/crates/core/src/scenarios/protocols/mod.rs index 99f0b0967..dbcb89738 100644 --- a/crates/core/src/scenarios/protocols/mod.rs +++ b/crates/core/src/scenarios/protocols/mod.rs @@ -1 +1,2 @@ +pub mod goonfi; pub mod pump; diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index 217ac8039..d9cff132d 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -23,6 +23,14 @@ 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 GOONFI_V1_ORACLE_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/oracle_overrides.yaml"); + +pub const GOONFI_V1_MARKET_OVERRIDES_CONTENT: &str = + include_str!("./protocols/goonfi/v1/market_overrides.yaml"); + pub const KAMINO_SCOPE_IDL_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/idl.json"); pub const KAMINO_SCOPE_OVERRIDES_CONTENT: &str = include_str!("./protocols/kamino/scope/v1/overrides.yaml"); @@ -76,6 +84,8 @@ impl TemplateRegistry { default.load_raydium_overrides(); default.load_meteora_overrides(); default.load_kamino_overrides(); + default.load_bisonfi_overrides(); + default.load_goonfi_overrides(); default.load_drift_overrides(); default.load_whirlpool_overrides(); default.load_spl_token_overrides(); @@ -116,6 +126,16 @@ impl TemplateRegistry { ); } + pub fn load_bisonfi_overrides(&mut self) { + self.load_protocol_overrides_without_idl(BISONFI_OVERRIDES_CONTENT, "bisonfi"); + } + + /// GoonFi writes two account shapes - the price oracle and the market that band-guards it. + pub fn load_goonfi_overrides(&mut self) { + self.load_protocol_overrides_without_idl(GOONFI_V1_ORACLE_OVERRIDES_CONTENT, "goonfi"); + self.load_protocol_overrides_without_idl(GOONFI_V1_MARKET_OVERRIDES_CONTENT, "goonfi"); + } + pub fn load_kamino_overrides(&mut self) { self.load_protocol_overrides(KAMINO_V1_IDL_CONTENT, KAMINO_V1_OVERRIDES_CONTENT, "kamino"); @@ -189,7 +209,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,13 +518,19 @@ mod tests { // Pyth (1) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift (4) + Meteora (2) // + Kamino (Lend 17, Scope 3, Farms 5, Swap 2, Vault 5, Liquidity 4 = 36) - // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) = 62 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + BisonFi (4) + // + GoonFi (oracle 3 + market 1) = 70 assert_eq!( registry.count(), - 62, - "Registry should load 62 templates total" + 70, + "Registry should load 70 templates total" ); + assert!(registry.contains("goonfi-price")); + assert!(registry.contains("goonfi-stale-quote")); + assert!(registry.contains("goonfi-freshness")); + assert!(registry.contains("goonfi-reference-band")); + assert!(registry.contains("pyth-price-feed-v2")); assert!(registry.contains("jupiter-token-ledger-override")); @@ -717,7 +761,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 +1201,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 +1230,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 +1395,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 +1408,7 @@ mod tests { .get("kamino-obligation-positions") .expect("kamino-obligation-positions should exist"); let resolved = surfpool_types::resolve_idl_type( - &obligation.idl, + obligation.idl(), &obligation.account_type, "deposits.0.deposit_reserve", ) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index baff46c76..da32e51cb 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,57 @@ 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"); + if let Err(e) = raw_layout.guard_owner(account.owner()) { + warn!( + "Raw-layout override {} refused on {}: {}", + override_instance.id, account_pubkey, e + ); + continue; + } + let properties = template.properties; + match raw_layout.materialize( + account.data(), + &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()) { @@ -4474,10 +4536,6 @@ impl SurfnetSvm { Ok(fixtures) } - /// Registers a scenario for execution by scheduling its overrides - /// - /// The `slot` parameter is the base slot from which relative override slot heights are calculated. - /// If not provided, uses the current slot. pub fn register_scenario( &mut self, scenario: surfpool_types::Scenario, @@ -5809,10 +5867,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 000000000..dd344f0b1 --- /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/goonfi/mod.rs b/crates/core/src/tests/goonfi/mod.rs new file mode 100644 index 000000000..9bbaaedc0 --- /dev/null +++ b/crates/core/src/tests/goonfi/mod.rs @@ -0,0 +1,1110 @@ +//! Behavioral proofs for GoonFi's oracle and market layouts against the current deployed program. +//! +//! GoonFi V2 prices swaps from a per-market oracle account owned by a companion publisher +//! program, not from the market account itself. The market account carries the pair's identities +//! (mints, vaults, oracle pointer) in cleartext plus the reference band that guards the oracle +//! price; the oracle carries bid/ask, a u32 freshness slot, and a dynamic staleness multiplier. +//! +//! Run serially against mainnet: +//! `cargo test -p surfpool-core --features integration-tests tests::goonfi -- --test-threads=1` + +use std::collections::HashMap; + +use sha2::{Digest, Sha256}; +use solana_account::Account; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_pack::Pack; +use solana_program_runtime::{ + declare_process_instruction, solana_sbpf::program::BuiltinFunctionDefinition, +}; +use solana_pubkey::Pubkey; + +use crate::{ + scenarios::{ + TemplateRegistry, + protocols::goonfi::v1::{ + GoonfiMarket, build_goonfi_price_scenario, discover_goonfi_markets, + }, + }, + surfnet::svm::SurfnetSvm, + tests::live, +}; + +const GOONFI_PROGRAM: &str = "goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"; +const GOONFI_PROGRAMDATA: &str = "124gUYwjVnJQ4sJsFug9gHPzPLEtwCbAQC5LkbaDgx9s"; +const ORACLE_PROGRAMDATA: &str = "7btzN5NEjnZqdQECwT88XhixeGnZjz5YKqjYGYKxKE5z"; +const GOONFI_ORACLE_PROGRAM: &str = "dijkbkCAKfFTCxQg3u1pg82gVU1jJGHBBRcteD11mBu"; +const GOONFI_GLOBAL: &str = "BNrK9LpEn65QA4TyBLVSMdngW3XHj3xLfFPwGdCBv8wV"; +const JUPITER_PROGRAM: &str = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; +const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +const CURRENT_DEPLOY_SLOT: u64 = 438_563_879; +const CURRENT_ELF_SHA256: &str = "73e580830356c7a086d8bec422790b2600108a8129faebdfc055bd46d8936c2e"; +const ORACLE_DEPLOY_SLOT: u64 = 404_369_628; +const ORACLE_ELF_SHA256: &str = "0fc545beb6abd12682ae68a27fa1e2a22d86d5d1dbbbe6d1e8f49e53ef762695"; + +/// Deployed-program error codes, proven by the replay runs below. +const ERROR_STALE_ORACLE: &str = "Custom(21)"; +const ERROR_PRICE_OUT_OF_BAND: &str = "Custom(36)"; +const ERROR_MIN_AMOUNT_OUT: &str = "Custom(15)"; +const ERROR_INSUFFICIENT_LIQUIDITY: &str = "Custom(1)"; + +/// Oracle layout: both prices are the human pair price times 10^6, independent of mint decimals. +/// The freshness slot is 4 bytes; the u32 beside it is the decay-rate multiplier around 10^6 - +/// it scales how fast a quote degrades with age and does not move the rejection boundary. +const ORACLE_BID_OFFSET: usize = 0; +const ORACLE_ASK_OFFSET: usize = 8; +const ORACLE_SLOT_OFFSET: usize = 16; +const ORACLE_MULTIPLIER_OFFSET: usize = 20; +const ORACLE_TS_MS_OFFSET: usize = 24; + +/// Market-account fields the flows touch or read. The two reference prices band-guard the oracle; +/// the mint and oracle pointers identify the pair. +const MARKET_BASE_MINT_OFFSET: usize = 80; +const MARKET_QUOTE_MINT_OFFSET: usize = 112; +const MARKET_ORACLE_OFFSET: usize = 208; +const MARKET_REF_A_OFFSET: usize = 1712; +const MARKET_REF_B_OFFSET: usize = 1720; + +#[derive(Clone, Copy)] +struct MarketSpec { + market: &'static str, + base_vault: &'static str, + quote_vault: &'static str, + base_mint: &'static str, + quote_mint: &'static str, + oracle: &'static str, + amount_in: u64, +} + +/// The pair the captured reference swap traded, so the replay mirrors a known-good transaction. +const PRIMARY_MARKET: MarketSpec = MarketSpec { + market: "HBDaV4ndLuVe6qK1vGCXReon4B1DJKa9UrbqP8cVqywx", + base_vault: "4KDPiofhBxLMuTuvaYtMAqY6e5DnzbHLB6i7eeU239f6", + quote_vault: "DAogoedaaCcn2SzTc3yi7bWgTWYv5MwoTj6ySgw9snLS", + base_mint: "A7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaS", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "vCDwWKdqPHYAP7q5zXY6xk3XC5Ct5oqCs5fdpoosPNq", + amount_in: 25_109_852, +}; + +const SOL_USDC_MARKET: MarketSpec = MarketSpec { + market: "GMCJvYGf5Ex2ARiMquaBDqU6iKM8uiEQkB8jCnoNfHpC", + base_vault: "8ncU5YW1CQwvr4gs7buH57bW58e86TDau4STrCJBuz8z", + quote_vault: "EunHLeqeJKvxnCPQSytnBP63HJVk2fbHceiKKpngyAo8", + base_mint: "So11111111111111111111111111111111111111112", + quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + oracle: "7yecFG22heommABQ5svcbQLK1Ua4ZrJsHPiktZ17jfm3", + amount_in: 1_000_000_000, +}; + +#[derive(Clone)] +struct GoonfiFork { + spec: MarketSpec, + elf: Vec, + global: Account, + market: Account, + base_vault: Account, + quote_vault: Account, + base_mint: Account, + quote_mint: Account, + oracle: Account, +} + +declare_process_instruction!(GoonfiCpiWrapper, 1, |invoke_context| { + let instruction = { + let context = invoke_context + .transaction_context + .get_current_instruction_context()?; + let accounts = (1..context.get_number_of_instruction_accounts()) + .map(|index| { + Ok(AccountMeta { + pubkey: *context.get_key_of_instruction_account(index)?, + is_signer: context.is_instruction_account_signer(index)?, + is_writable: context.is_instruction_account_writable(index)?, + }) + }) + .collect::, solana_instruction::error::InstructionError>>()?; + Instruction { + program_id: Pubkey::from_str_const(GOONFI_PROGRAM), + accounts, + data: context.get_instruction_data().to_vec(), + } + }; + invoke_context.native_invoke_signed(instruction, &[]) +}); + +async fn fetch_accounts(addresses: &[&str]) -> Vec { + let pubkeys: Vec = addresses + .iter() + .map(|address| Pubkey::from_str_const(address)) + .collect(); + live::fetch(&pubkeys).await +} + +async fn goonfi_fork(spec: MarketSpec) -> GoonfiFork { + // The ProgramData and global accounts total near a megabyte, which the public endpoint + // refuses to return alongside the market graph. Fetch the two big slow-moving accounts + // separately and keep the price-coupled market graph in one same-slot batch. + let mut big = fetch_accounts(&[GOONFI_PROGRAMDATA, GOONFI_GLOBAL, ORACLE_PROGRAMDATA]).await; + let mut accounts = fetch_accounts(&[ + spec.market, + spec.base_vault, + spec.quote_vault, + spec.base_mint, + spec.quote_mint, + spec.oracle, + ]) + .await; + let programdata = big.remove(0); + assert_eq!(programdata.data.len(), 252_429, "ProgramData size changed"); + assert_eq!( + u64::from_le_bytes(programdata.data[4..12].try_into().unwrap()), + CURRENT_DEPLOY_SLOT, + "GoonFi was redeployed; revalidate the raw layout" + ); + let elf = programdata.data[45..].to_vec(); + assert_eq!( + hex::encode(Sha256::digest(&elf)), + CURRENT_ELF_SHA256, + "GoonFi ELF changed without a ProgramData address change" + ); + // The publisher's identity is pinned too: its oracle accounts are the price templates' write + // targets, so a redeploy there also voids the layout evidence. + let oracle_programdata = big.pop().expect("oracle programdata fetched"); + assert_eq!( + oracle_programdata.data.len(), + 557, + "oracle publisher ProgramData size changed" + ); + assert_eq!( + u64::from_le_bytes(oracle_programdata.data[4..12].try_into().unwrap()), + ORACLE_DEPLOY_SLOT, + "the oracle publisher was redeployed; revalidate the oracle layout" + ); + assert_eq!( + hex::encode(Sha256::digest(&oracle_programdata.data[45..])), + ORACLE_ELF_SHA256, + "oracle publisher ELF changed without a ProgramData address change" + ); + + GoonfiFork { + spec, + elf, + global: big.remove(0), + market: accounts.remove(0), + base_vault: accounts.remove(0), + quote_vault: accounts.remove(0), + base_mint: accounts.remove(0), + quote_mint: accounts.remove(0), + oracle: accounts.remove(0), + } +} + +fn with_controlled_inventory(mut fork: GoonfiFork) -> GoonfiFork { + // Publishers can drain live vaults to dust. Fund only the local fixture so price and age + // assertions measure those controls rather than unrelated, time-varying inventory limits. + for (address, vault, mint_address, mint) in [ + ( + fork.spec.base_vault, + &mut fork.base_vault, + fork.spec.base_mint, + &fork.base_mint, + ), + ( + fork.spec.quote_vault, + &mut fork.quote_vault, + fork.spec.quote_mint, + &fork.quote_mint, + ), + ] { + assert_eq!(vault.owner, spl_token_interface::ID); + assert_eq!(mint.owner, spl_token_interface::ID); + let mint_state = spl_token_interface::state::Mint::unpack(&mint.data) + .expect("controlled fixture mint must remain valid"); + let mut token = spl_token_interface::state::Account::unpack(&vault.data) + .expect("controlled fixture vault must remain valid"); + assert_eq!(token.mint, Pubkey::from_str_const(mint_address)); + assert_eq!(token.owner, Pubkey::from_str_const(fork.spec.market)); + let minimum_amount = 10u64 + .checked_pow(u32::from(mint_state.decimals)) + .and_then(|unit| unit.checked_mul(10_000)) + .expect("10,000 whole fixture tokens must fit u64"); + let original_amount = token.amount; + token.amount = token.amount.max(minimum_amount); + let original_data = vault.data.clone(); + spl_token_interface::state::Account::pack(token, &mut vault.data) + .expect("pack controlled fixture vault"); + if let solana_program_option::COption::Some(reserve) = token.is_native { + vault.lamports = reserve + .checked_add(token.amount) + .expect("controlled native vault funding fits u64"); + } + assert_only_ranges_changed(&original_data, &vault.data, &[(64, 72)]); + eprintln!( + "GoonFi controlled local inventory {address}: captured {original_amount}, prepared {} raw units; market, oracle and deployed ELF remain captured", + token.amount + ); + } + fork +} + +fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = vec![0u8; 165]; + data[0..32].copy_from_slice(mint.as_ref()); + data[32..64].copy_from_slice(owner.as_ref()); + data[64..72].copy_from_slice(&amount.to_le_bytes()); + data[108] = 1; + data +} + +fn native_token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec { + let mut data = token_account(mint, owner, amount); + data[109..113].copy_from_slice(&1u32.to_le_bytes()); + data[113..121].copy_from_slice(&2_039_280u64.to_le_bytes()); + data +} + +fn token_amount(data: &[u8]) -> u64 { + u64::from_le_bytes(data[64..72].try_into().unwrap()) +} + +fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +fn read_u32(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) +} + +fn write_u64(data: &mut [u8], offset: usize, value: u64) { + data[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn oracle_slot(data: &[u8]) -> u64 { + u64::from(read_u32(data, ORACLE_SLOT_OFFSET)) +} + +fn scale_prices(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [ORACLE_BID_OFFSET, ORACLE_ASK_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn scale_refs(data: &mut [u8], numerator: u64, denominator: u64) { + for offset in [MARKET_REF_A_OFFSET, MARKET_REF_B_OFFSET] { + let scaled = (u128::from(read_u64(data, offset)) * u128::from(numerator) + / u128::from(denominator)) as u64; + write_u64(data, offset, scaled); + } +} + +fn assert_only_ranges_changed(before: &[u8], after: &[u8], ranges: &[(usize, usize)]) { + assert_eq!(after.len(), before.len()); + for index in live::diff_indices(before, after) { + assert!( + ranges + .iter() + .any(|(start, end)| (*start..*end).contains(&index)), + "unexpected changed byte at {index}" + ); + } +} + +struct RunConfig { + amount_in: u64, + is_bid: u8, + min_amount_out: u64, + /// Slots past the oracle's snapshot update slot at which the swap executes. + clock_slot_age: u64, + /// Seconds past the oracle's snapshot publish time at which the swap executes. + clock_ts_age: i64, +} + +impl RunConfig { + fn sell(amount_in: u64) -> Self { + Self { + amount_in, + is_bid: 0, + min_amount_out: 1, + clock_slot_age: 1, + clock_ts_age: 1, + } + } + + fn buy(amount_in: u64) -> Self { + Self { + is_bid: 1, + ..Self::sell(amount_in) + } + } + + fn sell_at_age(amount_in: u64, clock_slot_age: u64) -> Self { + Self { + clock_slot_age, + ..Self::sell(amount_in) + } + } +} + +fn goonfi_run( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_full(fork, config, mutate_oracle, |_| {}) +} + +fn goonfi_run_full( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result { + goonfi_run_capturing_oracle(fork, config, mutate_oracle, mutate_market) + .map(|(amount_out, _)| amount_out) +} + +/// Executes one GoonFi swap in LiteSVM against forked mainnet state: the deployed ELF, driven +/// through a wrapper builtin standing in for Jupiter, reproducing the aggregator-routed shape +/// every live swap has. Returns the fill and the oracle's post-execution bytes. +fn goonfi_run_capturing_oracle( + fork: &GoonfiFork, + config: RunConfig, + mutate_oracle: impl FnOnce(&mut Vec), + mutate_market: impl FnOnce(&mut Vec), +) -> Result<(u64, Vec), String> { + use litesvm::LiteSVM; + use solana_keypair::Keypair; + use solana_signer::Signer; + use solana_transaction::Transaction; + + let program_id = Pubkey::from_str_const(GOONFI_PROGRAM); + let global_key = Pubkey::from_str_const(GOONFI_GLOBAL); + let market_key = Pubkey::from_str_const(fork.spec.market); + let base_vault_key = Pubkey::from_str_const(fork.spec.base_vault); + let quote_vault_key = Pubkey::from_str_const(fork.spec.quote_vault); + let base_mint_key = Pubkey::from_str_const(fork.spec.base_mint); + let quote_mint_key = Pubkey::from_str_const(fork.spec.quote_mint); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let token_program = Pubkey::from_str_const(TOKEN_PROGRAM); + + let mut oracle = fork.oracle.data.clone(); + mutate_oracle(&mut oracle); + let mut market = fork.market.data.clone(); + mutate_market(&mut market); + // Ages are measured from the snapshot the fork fetched, not from mutated bytes, so a + // re-stamped freshness field changes the account's age rather than moving the clock. + let oracle_update_slot = oracle_slot(&fork.oracle.data); + let oracle_ts_seconds = (read_u64(&fork.oracle.data, ORACLE_TS_MS_OFFSET) / 1_000) as i64; + + let mut svm = LiteSVM::new() + .with_sigverify(false) + .with_blockhash_check(false); + svm.add_program(program_id, &fork.elf) + .map_err(|error| format!("add_program: {error:?}"))?; + svm.add_builtin( + Pubkey::from_str_const(JUPITER_PROGRAM), + GoonfiCpiWrapper::register, + ); + let mut clock: solana_clock::Clock = svm.get_sysvar(); + clock.slot = oracle_update_slot + config.clock_slot_age; + clock.unix_timestamp = oracle_ts_seconds + config.clock_ts_age; + svm.set_sysvar(&clock); + svm.set_account( + Pubkey::from_str_const("SysvarLastRestartS1ot1111111111111111111111"), + Account { + lamports: 1_000_000, + data: 246_464_040u64.to_le_bytes().to_vec(), + owner: Pubkey::from_str_const("Sysvar1111111111111111111111111111111111111"), + executable: false, + rent_epoch: 0, + }, + ) + .map_err(|error| format!("set last restart slot: {error:?}"))?; + + let mut oracle_account = fork.oracle.clone(); + oracle_account.data = oracle; + let mut market_account = fork.market.clone(); + market_account.data = market; + for (key, account) in [ + (global_key, fork.global.clone()), + (market_key, market_account), + (base_vault_key, fork.base_vault.clone()), + (quote_vault_key, fork.quote_vault.clone()), + (base_mint_key, fork.base_mint.clone()), + (quote_mint_key, fork.quote_mint.clone()), + (oracle_key, oracle_account), + ] { + svm.set_account(key, account) + .map_err(|error| format!("set {key}: {error:?}"))?; + } + + let taker = Keypair::new(); + svm.airdrop(&taker.pubkey(), 10_000_000_000) + .map_err(|error| format!("airdrop: {error:?}"))?; + let user_base_key = Pubkey::new_unique(); + let user_quote_key = Pubkey::new_unique(); + let (base_funds, quote_funds) = if config.is_bid == 0 { + (config.amount_in, 0) + } else { + (0, config.amount_in) + }; + let user_account = |mint: &Pubkey, amount: u64| { + let is_native = + mint == &Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + Account { + lamports: if is_native { + amount.saturating_add(2_039_280) + } else { + 10_000_000 + }, + data: if is_native { + native_token_account(mint, &taker.pubkey(), amount) + } else { + token_account(mint, &taker.pubkey(), amount) + }, + owner: token_program, + executable: false, + rent_epoch: 0, + } + }; + svm.set_account(user_base_key, user_account(&base_mint_key, base_funds)) + .map_err(|error| format!("set user base: {error:?}"))?; + svm.set_account(user_quote_key, user_account("e_mint_key, quote_funds)) + .map_err(|error| format!("set user quote: {error:?}"))?; + + let mut data = vec![1u8, config.is_bid]; + data.extend_from_slice(&config.amount_in.to_le_bytes()); + data.extend_from_slice(&config.min_amount_out.to_le_bytes()); + let mut budget = vec![2u8]; + budget.extend_from_slice(&1_400_000u32.to_le_bytes()); + let instructions = vec![ + Instruction { + program_id: Pubkey::from_str_const("ComputeBudget111111111111111111111111111111"), + accounts: vec![], + data: budget, + }, + Instruction { + program_id: Pubkey::from_str_const(JUPITER_PROGRAM), + accounts: vec![ + AccountMeta::new_readonly(program_id, false), + AccountMeta::new(taker.pubkey(), true), + AccountMeta::new(market_key, false), + AccountMeta::new(user_base_key, false), + AccountMeta::new(user_quote_key, false), + AccountMeta::new(base_vault_key, false), + AccountMeta::new(quote_vault_key, false), + AccountMeta::new_readonly(base_mint_key, false), + AccountMeta::new_readonly(quote_mint_key, false), + AccountMeta::new_readonly(oracle_key, false), + AccountMeta::new_readonly(global_key, false), + AccountMeta::new_readonly( + Pubkey::from_str_const("Sysvar1nstructions1111111111111111111111111"), + false, + ), + AccountMeta::new_readonly(token_program, false), + AccountMeta::new_readonly(token_program, false), + ], + data, + }, + ]; + let mut message = solana_message::Message::new(&instructions, Some(&taker.pubkey())); + message.recent_blockhash = svm.latest_blockhash(); + let signature_count = message.header.num_required_signatures as usize; + let mut transaction = Transaction::new_unsigned(message); + transaction.signatures = vec![solana_signature::Signature::default(); signature_count]; + transaction.signatures[0] = taker.sign_message(&transaction.message.serialize()); + + svm.send_transaction(transaction) + .map_err(|error| format!("{error:?}"))?; + let destination = if config.is_bid == 0 { + user_quote_key + } else { + user_base_key + }; + let amount_out = token_amount( + &svm.get_account(&destination) + .expect("destination account") + .data, + ); + let oracle_after = svm.get_account(&oracle_key).expect("oracle account").data; + Ok((amount_out, oracle_after)) +} + +/// Forks a market by its address alone, resolving vaults, mints, and oracle from the market +/// account's own pointers. Used where a fixture market outside the two hardcoded specs is needed. +async fn fork_from_market(market: &'static str, amount_in: u64) -> GoonfiFork { + let accounts = fetch_accounts(&[market]).await; + let data = &accounts[0].data; + let field = |offset: usize| -> &'static str { + Box::leak( + Pubkey::new_from_array(data[offset..offset + 32].try_into().unwrap()) + .to_string() + .into_boxed_str(), + ) + }; + let spec = MarketSpec { + market, + base_vault: field(144), + quote_vault: field(176), + base_mint: field(MARKET_BASE_MINT_OFFSET), + quote_mint: field(MARKET_QUOTE_MINT_OFFSET), + oracle: field(MARKET_ORACLE_OFFSET), + amount_in, + }; + goonfi_fork(spec).await +} + +/// Materializes the goonfi-stale-quote template with its default lead onto the fork's live +/// oracle bytes, asserts the exact 4-byte slot it wrote, and proves the deployed program then +/// rejects the swap. This is the template's own default doing the aging, not a hand-picked age. +fn stale_template_default_rejects(fork: &GoonfiFork, amount: u64) { + let registry = TemplateRegistry::new(); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let snapshot_slot = oracle_slot(&fork.oracle.data); + let aged = stale + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &stale.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + snapshot_slot, + ) + .expect("materialize stale default"); + assert_eq!( + oracle_slot(&aged), + snapshot_slot - 2_000, + "the default lead must write exactly slot minus 2000" + ); + assert_only_ranges_changed(&fork.oracle.data, &aged, &[(16, 20)]); + assert_rejects_with( + goonfi_run(fork, RunConfig::sell(amount), |oracle| { + *oracle = aged.clone() + }), + ERROR_STALE_ORACLE, + "a quote aged by the stale template's default lead", + ); +} + +fn assert_rejects_with(result: Result, code: &str, context: &str) { + match result { + Ok(amount) => panic!("{context}: expected {code}, got a fill of {amount}"), + Err(error) => assert!( + error.contains(code), + "{context}: expected {code} in: {error}" + ), + } +} + +#[tokio::test] +async fn goonfi_templates_guard_oracle_and_market_and_preserve_unwritten_bytes() { + let fork = goonfi_fork(PRIMARY_MARKET).await; + let registry = TemplateRegistry::new(); + let price = registry.get("goonfi-price").expect("price template"); + let stale = registry.get("goonfi-stale-quote").expect("stale template"); + let fresh = registry + .get("goonfi-freshness") + .expect("freshness template"); + let band = registry + .get("goonfi-reference-band") + .expect("reference-band template"); + + let oracle_layout = price.raw_layout.as_ref().expect("oracle raw layout"); + let market_layout = band.raw_layout.as_ref().expect("market raw layout"); + assert!(oracle_layout.guard(&fork.oracle.data).is_ok()); + assert!(market_layout.guard(&fork.market.data).is_ok()); + assert!(oracle_layout.guard(&fork.oracle.data[..16]).is_err()); + assert!(market_layout.guard(&fork.market.data[..2000]).is_err()); + let mut flipped = fork.market.data.clone(); + flipped[0] ^= 0xff; + assert!(market_layout.guard(&flipped).is_err()); + + let priced = oracle_layout + .materialize( + &fork.oracle.data, + &price.properties, + &HashMap::from([ + ("bid_price_x1e6".to_string(), serde_json::json!("123456789")), + ("ask_price_x1e6".to_string(), serde_json::json!("123456790")), + ]), + 0, + ) + .expect("materialize price"); + assert_eq!(read_u64(&priced, ORACLE_BID_OFFSET), 123_456_789); + assert_eq!(read_u64(&priced, ORACLE_ASK_OFFSET), 123_456_790); + assert_only_ranges_changed(&fork.oracle.data, &priced, &[(0, 16)]); + + // The freshness slot is 4 bytes wide: the dynamic multiplier right after it must survive. + let target_slot = 500_000_123; + for (template, label) in [(stale, "stale"), (fresh, "freshness")] { + let stamped = template + .raw_layout + .as_ref() + .expect("oracle raw layout") + .materialize( + &fork.oracle.data, + &template.properties, + &HashMap::from([("last_update_slot".to_string(), serde_json::Value::Null)]), + target_slot, + ) + .unwrap_or_else(|error| panic!("materialize {label}: {error}")); + assert_only_ranges_changed(&fork.oracle.data, &stamped, &[(16, 20)]); + assert_eq!( + read_u32(&stamped, ORACLE_MULTIPLIER_OFFSET), + read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET), + "{label} clobbered the staleness multiplier" + ); + } + + let banded = market_layout + .materialize( + &fork.market.data, + &band.properties, + &HashMap::from([ + ( + "reference_price_a_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ( + "reference_price_b_x1e6".to_string(), + serde_json::json!("123456789"), + ), + ]), + 0, + ) + .expect("materialize reference band"); + assert_eq!(read_u64(&banded, MARKET_REF_A_OFFSET), 123_456_789); + assert_eq!(read_u64(&banded, MARKET_REF_B_OFFSET), 123_456_789); + assert_only_ranges_changed(&fork.market.data, &banded, &[(1712, 1728)]); +} + +/// Proves the exact state the real builder prepares, end to end: `build_goonfi_price_scenario` +/// output registers and materializes through the production path, touching only its declared +/// bytes, and the deployed program then fills at the prepared price. The scenario is anchored at +/// the oracle's snapshot slot so the materialized freshness stamp matches the replay clock. +async fn builder_prepares_and_the_program_fills(fork: &GoonfiFork) { + let market_key = Pubkey::from_str_const(fork.spec.market); + let oracle_key = Pubkey::from_str_const(fork.spec.oracle); + let market = + GoonfiMarket::validate(market_key, &fork.market, &fork.oracle).expect("validate market"); + let live_bid = read_u64(&fork.oracle.data, ORACLE_BID_OFFSET); + let target = live_bid * 3 / 2; + let price = format!("{}.{:06}", target / 1_000_000, target % 1_000_000); + let preparation = + build_goonfi_price_scenario(&market, &price).expect("build GoonFi price scenario"); + assert_eq!(preparation.price_x1e6, target); + + let base_slot = oracle_slot(&fork.oracle.data); + let (mut svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + svm.inner + .set_account(market_key, fork.market.clone()) + .expect("seed GoonFi market"); + svm.inner + .set_account(oracle_key, fork.oracle.clone()) + .expect("seed GoonFi oracle"); + svm.register_scenario(preparation.scenario, Some(base_slot)) + .expect("register GoonFi scenario"); + svm.materialize_overrides_for_slot(&None, base_slot) + .await + .expect("materialize GoonFi scenario"); + + let oracle = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + let market_data = svm + .inner + .get_account(&market_key) + .expect("get market") + .expect("market present") + .data; + assert_eq!(read_u64(&oracle, ORACLE_BID_OFFSET), target); + assert_eq!(read_u64(&oracle, ORACLE_ASK_OFFSET), target); + assert_eq!(oracle_slot(&oracle), base_slot); + assert_eq!(read_u64(&market_data, MARKET_REF_A_OFFSET), target); + assert_eq!(read_u64(&market_data, MARKET_REF_B_OFFSET), target); + assert_only_ranges_changed(&fork.oracle.data, &oracle, &[(0, 20)]); + assert_only_ranges_changed(&fork.market.data, &market_data, &[(1712, 1728)]); + + // The deployed program fills at the prepared price, against the exact materialized bytes. + let baseline = + goonfi_run(fork, RunConfig::sell(fork.spec.amount_in), |_| {}).expect("baseline sell"); + let prepared = goonfi_run_full( + fork, + RunConfig::sell(fork.spec.amount_in), + |data| *data = oracle.clone(), + |data| *data = market_data.clone(), + ) + .expect("sell against the builder-prepared state"); + let expected = (u128::from(baseline) * u128::from(target) / u128::from(live_bid)) as u64; + assert!( + prepared.abs_diff(expected) <= expected / 500, + "the prepared price must set the fill: {prepared} vs ~{expected}" + ); + + // Only the persistent freshness override re-applies on the next slot. + svm.materialize_overrides_for_slot(&None, base_slot + 1) + .await + .expect("materialize persistent GoonFi freshness"); + let next = svm + .inner + .get_account(&oracle_key) + .expect("get oracle") + .expect("oracle present") + .data; + assert_eq!(oracle_slot(&next), base_slot + 1); + assert_eq!(read_u64(&next, ORACLE_BID_OFFSET), target); + assert_only_ranges_changed(&oracle, &next, &[(16, 20)]); +} + +#[tokio::test] +async fn goonfi_builder_scenario_materializes_and_fills_across_oracle_and_market() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + builder_prepares_and_the_program_fills(&fork).await; +} + +#[tokio::test] +async fn goonfi_price_and_reference_band_control_the_deployed_program() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("baseline sell"); + assert!(baseline > 0); + + // No-op rewrite proves the encoding round-trips; the program cannot tell the bytes moved. + let noop = goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + let restated = read_u64(oracle, ORACLE_BID_OFFSET); + write_u64(oracle, ORACLE_BID_OFFSET, restated); + }) + .expect("no-op sell"); + assert_eq!(noop, baseline); + + // Coupled halve and double move the fill linearly in both directions. + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving the price must halve the fill: {halved} * 2 vs {baseline}" + ); + let doubled = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 2, 1), + |market| scale_refs(market, 2, 1), + ) + .expect("coupled doubled sell"); + assert!( + doubled.abs_diff(baseline * 2) <= baseline / 500, + "doubling the price must double the fill: {doubled} vs 2 * {baseline}" + ); + + // Decoupled moves reject: the band guards each direction against the venue-unfavorable side. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell(amount), |oracle| { + scale_prices(oracle, 2, 1) + }), + ERROR_PRICE_OUT_OF_BAND, + "sell with raised oracle and untouched reference band", + ); + assert_rejects_with( + goonfi_run(&fork, RunConfig::buy(100_000_000), |oracle| { + scale_prices(oracle, 1, 2) + }), + ERROR_PRICE_OUT_OF_BAND, + "buy with lowered oracle and untouched reference band", + ); + let coupled_buy = goonfi_run_full( + &fork, + RunConfig::buy(100_000_000), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("coupled halved buy"); + assert!(coupled_buy > 0); + + assert_rejects_with( + goonfi_run( + &fork, + RunConfig { + min_amount_out: u64::MAX, + ..RunConfig::sell(amount) + }, + |_| {}, + ), + ERROR_MIN_AMOUNT_OUT, + "sell with an impossible min_amount_out", + ); + + // Keep the successful trade size fixed so other input limits cannot mask vault depletion. + let mut limited = fork.clone(); + write_u64(&mut limited.quote_vault.data, 64, baseline); + let exact_inventory = goonfi_run(&limited, RunConfig::sell(amount), |_| {}) + .expect("sell with exactly enough quote inventory"); + assert_eq!(exact_inventory, baseline); + + write_u64(&mut limited.quote_vault.data, 64, baseline - 1); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell with quote inventory one atomic unit below the measured output", + ); + write_u64(&mut limited.quote_vault.data, 64, 0); + assert_rejects_with( + goonfi_run(&limited, RunConfig::sell(amount), |_| {}), + ERROR_INSUFFICIENT_LIQUIDITY, + "sell against a drained quote vault", + ); +} + +fn stamp_multiplier(data: &mut [u8], multiplier: u32) { + data[ORACLE_MULTIPLIER_OFFSET..ORACLE_MULTIPLIER_OFFSET + 4] + .copy_from_slice(&multiplier.to_le_bytes()); +} + +/// First rejection age in 15..=40 under the given multiplier, asserting fills decay +/// monotonically before it and every rejection carries the staleness error. +fn rejection_boundary(fork: &GoonfiFork, amount: u64, multiplier: u32) -> u64 { + let mut previous = u64::MAX; + let mut first_rejection = None; + for age in 15..=40 { + let result = goonfi_run(fork, RunConfig::sell_at_age(amount, age), |oracle| { + stamp_multiplier(oracle, multiplier) + }); + match result { + Ok(output) => { + assert!( + first_rejection.is_none(), + "age {age} filled after the window closed at {first_rejection:?}" + ); + assert!(output <= previous, "decay reversed at age {age}"); + previous = output; + } + Err(error) => { + assert!( + error.contains(ERROR_STALE_ORACLE), + "age {age}: expected {ERROR_STALE_ORACLE} in: {error}" + ); + first_rejection.get_or_insert(age); + } + } + } + first_rejection.expect("no rejection up to age 40") +} + +#[tokio::test] +async fn goonfi_stale_quote_decays_then_rejects_and_freshness_restores() { + let fork = with_controlled_inventory(goonfi_fork(PRIMARY_MARKET).await); + let amount = fork.spec.amount_in; + + let fresh = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("fresh sell"); + let aged = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |_| {}).expect("aged sell"); + assert!( + aged < fresh, + "the program decays a quote with age: {aged} at age 10 vs {fresh} at age 1" + ); + + // The boundary's source is per-market and unidentified; this range is a safety canary + // around the observed value, not a fixed protocol constant. + let live_multiplier = read_u32(&fork.oracle.data, ORACLE_MULTIPLIER_OFFSET); + let boundary = rejection_boundary(&fork, amount, live_multiplier); + assert!( + (15..=35).contains(&boundary), + "rejection boundary {boundary} left the observed range" + ); + + // The multiplier at offset 20 scales the decay, not the window: at half and double the live + // value the boundary stays put, the decay rate scales with it, and the program leaves the + // oracle bytes untouched. + let mut decay_per_multiplier = Vec::new(); + for (label, numerator, denominator) in [("half", 1u64, 2u64), ("live", 1, 1), ("double", 2, 1)] + { + let multiplier = + u32::try_from(u64::from(live_multiplier) * numerator / denominator).expect("fits u32"); + let mut expected_oracle = fork.oracle.data.clone(); + stamp_multiplier(&mut expected_oracle, multiplier); + + let (at_age_1, oracle_after) = goonfi_run_capturing_oracle( + &fork, + RunConfig::sell(amount), + |oracle| stamp_multiplier(oracle, multiplier), + |_| {}, + ) + .unwrap_or_else(|error| panic!("sell at {label} multiplier: {error}")); + assert_eq!( + oracle_after, expected_oracle, + "the swap must not write the oracle ({label} multiplier)" + ); + let at_age_10 = goonfi_run(&fork, RunConfig::sell_at_age(amount, 10), |oracle| { + stamp_multiplier(oracle, multiplier) + }) + .unwrap_or_else(|error| panic!("aged sell at {label} multiplier: {error}")); + decay_per_multiplier.push(at_age_1 - at_age_10); + + assert_eq!( + rejection_boundary(&fork, amount, multiplier), + boundary, + "the {label} multiplier must not move the rejection boundary" + ); + } + let [half, live, double] = decay_per_multiplier[..] else { + unreachable!() + }; + assert!( + double.abs_diff(live * 2) <= live / 25, + "doubling the multiplier must double the decay: {double} vs 2 * {live}" + ); + assert!( + (half * 2).abs_diff(live) <= live / 25, + "halving the multiplier must halve the decay: {half} * 2 vs {live}" + ); + + // The wall-clock timestamp beside the slot is not consulted. + let ts_aged = goonfi_run( + &fork, + RunConfig { + clock_ts_age: 3_600, + ..RunConfig::sell(amount) + }, + |_| {}, + ) + .expect("sell an hour of wall-clock later"); + assert_eq!(ts_aged, fresh); + + // Deep staleness rejects; re-stamping the u32 slot alone restores the quote, which is what + // the goonfi-freshness template does at every materialization. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |_| {}), + ERROR_STALE_ORACLE, + "sell at age 1000", + ); + stale_template_default_rejects(&fork, amount); + let restamped_slot = oracle_slot(&fork.oracle.data) + 1_000; + let restamped = goonfi_run(&fork, RunConfig::sell_at_age(amount, 1_000), |oracle| { + oracle[ORACLE_SLOT_OFFSET..ORACLE_SLOT_OFFSET + 4] + .copy_from_slice(&(restamped_slot as u32).to_le_bytes()); + }) + .expect("sell at age 1000 with a re-stamped slot"); + assert!( + restamped * 100 >= fresh * 99, + "a re-stamped quote must fill near full price: {restamped} vs {fresh}" + ); +} + +#[tokio::test] +async fn goonfi_second_market_proves_generic_price_and_staleness_layout() { + let fork = with_controlled_inventory(goonfi_fork(SOL_USDC_MARKET).await); + let amount = fork.spec.amount_in; + + let baseline = goonfi_run(&fork, RunConfig::sell(amount), |_| {}).expect("SOL/USDC sell"); + let halved = goonfi_run_full( + &fork, + RunConfig::sell(amount), + |oracle| scale_prices(oracle, 1, 2), + |market| scale_refs(market, 1, 2), + ) + .expect("SOL/USDC coupled halved sell"); + assert!( + (halved * 2).abs_diff(baseline) <= 4, + "halving must halve on the second market too: {halved} * 2 vs {baseline}" + ); + + let bought = goonfi_run(&fork, RunConfig::buy(100_000_000), |_| {}).expect("SOL/USDC buy"); + assert!(bought > 0); + + builder_prepares_and_the_program_fills(&fork).await; + + // Well past every observed window on this market tier; the stablecoin tier's deeper windows + // are covered by the stale-template default proof below. + assert_rejects_with( + goonfi_run(&fork, RunConfig::sell_at_age(amount, 200), |_| {}), + ERROR_STALE_ORACLE, + "SOL/USDC sell past the staleness window", + ); + + // The stablecoin tier fills at ages that reject every other market (USDT/USDC filled at age + // 100 live), so the stale template's -2000 default must out-age even that window. + let stable = with_controlled_inventory( + fork_from_market("EEUNhHsRoUVgJUFpkupmdF4v7uLUw1zhYLp7u9s8zFqG", 0).await, + ); + let stable_amount = 1_000_000; + let filled = goonfi_run(&stable, RunConfig::sell_at_age(stable_amount, 50), |_| {}) + .expect("USDT/USDC fills at an age that rejects every volatile market"); + assert!(filled > 0); + stale_template_default_rejects(&stable, stable_amount); +} + +#[tokio::test] +async fn goonfi_discovery_fetches_live_market_and_oracle_relationships() { + use std::collections::HashSet; + + let markets = discover_goonfi_markets(&live::client()) + .await + .expect("discover GoonFi markets through the real RPC client"); + assert!( + !markets.is_empty(), + "live GoonFi discovery returned no markets" + ); + let default = markets + .iter() + .find(|market| market.address == Pubkey::from_str_const(SOL_USDC_MARKET.market)) + .expect("live discovery must include the default SOL/USDC market"); + assert_eq!( + default.oracle, + Pubkey::from_str_const(SOL_USDC_MARKET.oracle) + ); + assert_eq!( + default.base_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.base_mint) + ); + assert_eq!( + default.quote_mint, + Pubkey::from_str_const(SOL_USDC_MARKET.quote_mint) + ); + assert_eq!((default.base_decimals, default.quote_decimals), (9, 6)); + let mut addresses = HashSet::new(); + let mut oracles = HashSet::new(); + for market in &markets { + assert!( + addresses.insert(market.address), + "duplicate discovered market {}", + market.address + ); + assert!( + oracles.insert(market.oracle), + "duplicate discovered oracle {}", + market.oracle + ); + } + for chunk in markets.chunks(40) { + let addresses: Vec = chunk + .iter() + .flat_map(|market| [market.address, market.oracle]) + .collect(); + let accounts = live::fetch(&addresses).await; + for (discovered, accounts) in chunk.iter().zip(accounts.chunks_exact(2)) { + let validated = GoonfiMarket::validate(discovered.address, &accounts[0], &accounts[1]) + .expect("discovered market and oracle must retain their live owners and layouts"); + assert_eq!( + validated.oracle, discovered.oracle, + "live market oracle pointer changed" + ); + assert_eq!(&accounts[0].data[80..112], discovered.base_mint.as_ref()); + assert_eq!(&accounts[0].data[112..144], discovered.quote_mint.as_ref()); + } + } + eprintln!( + "GoonFi real RPC discovery verified {} unique live market/oracle pairs", + markets.len() + ); +} diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs index 9bdc0c1a5..17cbdee6c 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/live.rs b/crates/core/src/tests/live.rs new file mode 100644 index 000000000..a9891ecfb --- /dev/null +++ b/crates/core/src/tests/live.rs @@ -0,0 +1,68 @@ +//! Shared plumbing for tests that read mainnet. +//! +//! Set `SURFPOOL_TEST_RPC_URL` to use a private endpoint if the public one rate-limits. + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; + +use crate::surfnet::remote::SurfnetRemoteClient; + +pub const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +pub const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +pub fn client() -> SurfnetRemoteClient { + SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ) +} + +/// Fetches the accounts in one request, so every account returned is from the same slot. +pub async fn fetch(addresses: &[Pubkey]) -> Vec { + // The public endpoint throttles and intermittently 503s, which has nothing to do with what + // the callers assert. Retry a few times with backoff so a transient refusal is not read as a + // failure. + let mut attempt = 0; + let mut errors = Vec::new(); + let results = loop { + match client() + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + { + Ok(results) => break results, + Err(error) if attempt < 4 => { + attempt += 1; + errors.push(format!("attempt {attempt}: {error}")); + tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await; + } + Err(error) => { + errors.push(format!("attempt {}: {error}", attempt + 1)); + panic!( + "failed to fetch {addresses:?} from mainnet after {} attempts: {}", + errors.len(), + errors.join("; ") + ); + } + } + }; + + results + .into_iter() + .zip(addresses) + .map(|(result, address)| { + result.map_account().unwrap_or_else(|_| { + panic!("{address} no longer exists on mainnet; the integration needs a new address") + }) + }) + .collect() +} + +/// The offsets at which two buffers differ. +pub fn diff_indices(left: &[u8], right: &[u8]) -> Vec { + left.iter() + .zip(right) + .enumerate() + .filter(|(_, (a, b))| a != b) + .map(|(index, _)| index) + .collect() +} diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index b2dd37925..e239a147f 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -1,7 +1,13 @@ +#[cfg(feature = "integration-tests")] +pub mod bisonfi; +#[cfg(feature = "integration-tests")] +pub mod goonfi; pub mod helpers; pub mod integration; #[cfg(feature = "integration-tests")] pub mod kamino; +#[cfg(feature = "integration-tests")] +pub mod live; pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; diff --git a/crates/core/src/tests/pump/mod.rs b/crates/core/src/tests/pump/mod.rs index 960ce8e2f..650662766 100644 --- a/crates/core/src/tests/pump/mod.rs +++ b/crates/core/src/tests/pump/mod.rs @@ -157,9 +157,12 @@ async fn real_mainnet_accounts_round_trip_unchanged() { let template = registry .get(template_id) .unwrap_or_else(|| panic!("template {template_id} should exist")); - - let account_def = template + let idl = template .idl + .as_ref() + .unwrap_or_else(|| panic!("Pump template {template_id} must carry an IDL")); + + let account_def = idl .accounts .iter() .find(|a| a.name == *account_name) @@ -171,7 +174,7 @@ async fn real_mainnet_accounts_round_trip_unchanged() { ); let forged = surfnet_svm - .get_forged_account_data(&pubkey, &account.data, &template.idl, &HashMap::new()) + .get_forged_account_data(&pubkey, &account.data, idl, &HashMap::new()) .unwrap_or_else(|e| { panic!( "live mainnet {account_name} {address} failed to decode/re-encode with the \ @@ -226,8 +229,12 @@ async fn override_on_real_account_touches_only_target_bytes() { ), ("complete".to_string(), serde_json::json!(true)), ]); + let curve_idl = curve + .idl + .as_ref() + .expect("Pump curve template must carry an IDL"); let forged = surfnet_svm - .get_forged_account_data(&pubkey, curve_data, &curve.idl, &overrides) + .get_forged_account_data(&pubkey, curve_data, curve_idl, &overrides) .expect("curve override on the live bonding curve"); assert_eq!( forged.len(), @@ -265,8 +272,12 @@ async fn override_on_real_account_touches_only_target_bytes() { serde_json::json!(5_000_000_000i64), ), ]); + let pool_idl = pool + .idl + .as_ref() + .expect("Pump AMM template must carry an IDL"); let forged = surfnet_svm - .get_forged_account_data(&pubkey, pool_data, &pool.idl, &overrides) + .get_forged_account_data(&pubkey, pool_data, pool_idl, &overrides) .expect("pool override on the live canonical pool"); assert_eq!( forged.len(), diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 0ef4ebe29..37651d608 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -21,6 +21,8 @@ rmcp = { workspace = true, features = ["transport-io", "transport-sse-server", " serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" +solana-account = { workspace = true } +solana-commitment-config = { workspace = true } solana-keypair = { workspace = true } solana-pubkey = { workspace = true } solana-signer = { workspace = true } diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 7ce4c3e08..6614db201 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -19,8 +19,14 @@ use start_surfnet::StartSurfnetResponse; use surfpool_core::{ scenarios::{ TemplateRegistry, - protocols::pump::v1::graduation_builder::{ - build_pump_graduation_scenario, pump_graduation_addresses, + protocols::{ + goonfi::v1::{ + GoonfiMarket, build_goonfi_liquidity_scenario, build_goonfi_price_scenario, + discover_goonfi_markets, vault_addresses, + }, + pump::v1::graduation_builder::{ + build_pump_graduation_scenario, pump_graduation_addresses, + }, }, }, solana_account::Account, @@ -37,6 +43,48 @@ use crate::helpers::find_next_available_surfnet_port; mod set_token_account; mod start_surfnet; +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListGoonfiMarketsParams { + #[schemars(description = "Port of the selected local Surfnet RPC; default 8899.")] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateGoonfiPriceScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "The price of one base token in quote tokens, as a positive decimal string such as \"99.74\". Not atomic units: GoonFi prices are decimals-independent." + )] + pub price: String, + #[schemars( + description = "The port of the target running local surfnet instance (e.g., 8899, 18899, 28899, etc.). Omit to use the default port, 8899." + )] + pub surfnet_port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CreateGoonfiLiquidityScenarioParams { + #[schemars( + description = "The GoonFi market account. Resolve one through list_goonfi_markets; omit to use the default SOL/USDC market." + )] + pub market: Option, + #[schemars( + description = "Base vault liquidity to keep, in basis points: 0 drains the base vault so swaps are rejected for insufficient liquidity (0x1), 10000 leaves it unchanged. Defaults to 0." + )] + pub base_remaining_bps: Option, + #[schemars( + description = "Quote vault liquidity to keep, in basis points: 0 drains the quote vault, 10000 leaves it unchanged. Defaults to 0." + )] + pub quote_remaining_bps: Option, + #[schemars( + description = "The port of the target running local surfnet instance (e.g., 8899, 18899, 28899, etc.). Omit to use the default port, 8899." + )] + pub surfnet_port: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct StartSurfnetParams { #[schemars( @@ -399,6 +447,38 @@ impl Surfpool { .collect()) } + async fn fetch_goonfi_market( + &self, + address: Option<&str>, + surfnet_port: Option, + ) -> Result { + let market_address = match address.map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => Pubkey::from_str(value) + .map_err(|error| format!("Invalid GoonFi market pubkey: {error}"))?, + }; + let market_account = self + .fetch_surfnet_accounts(surfnet_port, &[market_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi market account {market_address} was not found"))?; + let oracle_address = + GoonfiMarket::oracle_address(&market_account).map_err(|error| error.to_string())?; + let oracle_account = self + .fetch_surfnet_accounts(surfnet_port, &[oracle_address]) + .await? + .into_iter() + .next() + .flatten() + .ok_or_else(|| format!("GoonFi oracle {oracle_address} was not found"))?; + GoonfiMarket::validate(market_address, &market_account, &oracle_account) + .map_err(|error| error.to_string()) + } + async fn stage_scenario(&self, scenario: Scenario) -> Result { let endpoint = format!( "http://127.0.0.1:{}/v1/scenarios", @@ -1001,6 +1081,141 @@ impl Surfpool { self.stage_scenario(preparation.scenario).await } + #[tool( + description = "Lists GoonFi markets discovered from program accounts on the selected Surfnet. Returns market and oracle addresses, pair labels, and base/quote mint identities and decimals. Validates market, oracle and mint accounts. Use address for scenario creation and oracle for oracle templates. Unknown symbols use full mint addresses." + )] + async fn list_goonfi_markets( + &self, + Parameters(params): Parameters, + ) -> Result { + let port = params.surfnet_port.unwrap_or(DEFAULT_RPC_PORT); + let client = SurfnetRemoteClient::new(format!("http://127.0.0.1:{port}")); + let markets = match discover_goonfi_markets(&client).await { + Ok(markets) => markets, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let markets = markets + .iter() + .map(|market| { + serde_json::json!({ + "address": market.address.to_string(), + "oracle": market.oracle.to_string(), + "label": market.label(), + "baseMint": market.base_mint.to_string(), + "quoteMint": market.quote_mint.to_string(), + "baseDecimals": market.base_decimals, + "quoteDecimals": market.quote_decimals, + }) + }) + .collect::>(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({"count": markets.len(), "markets": markets}).to_string(), + )])) + } + + #[tool( + description = "Creates one editable GoonFi price scenario for a live market. Reads the market account from the running surfnet, resolves its price oracle by the market's own pointer, and moves the oracle bid/ask together with the market's reference band while keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_price_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market = match self + .fetch_goonfi_market(params.market.as_deref(), params.surfnet_port) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let preparation = match build_goonfi_price_scenario(&market, ¶ms.price) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + + #[tool( + description = "Creates one editable GoonFi liquidity-drain scenario for a live market. Reads the market from the running surfnet, resolves its two token vaults by the market's own pointers, and scales each vault balance to the requested basis points (0 drains it so a swap is rejected for insufficient liquidity, 10000 leaves it unchanged), keeping the quote fresh. Prepares state; sends no swap. Resolve `market` through list_goonfi_markets." + )] + async fn create_goonfi_liquidity_scenario( + &self, + Parameters(params): Parameters, + ) -> Result { + let market_address = match params.market.as_deref().map(str::trim) { + None | Some("") => { + surfpool_core::scenarios::protocols::goonfi::v1::GOONFI_DEFAULT_MARKET + } + Some(value) => match Pubkey::from_str(value) { + Ok(market) => market, + Err(error) => { + return Ok(scenario_tool_error(format!( + "Invalid GoonFi market pubkey: {error}" + ))); + } + }, + }; + let market_account = match self + .fetch_surfnet_accounts(params.surfnet_port, &[market_address]) + .await + { + Ok(mut accounts) => match accounts.remove(0) { + Some(account) => account, + None => { + return Ok(scenario_tool_error(format!( + "GoonFi market account {market_address} was not found" + ))); + } + }, + Err(error) => return Ok(scenario_tool_error(error)), + }; + // Vaults and oracle are read from the market's own pointers, never taken from the caller. + let [base_vault, quote_vault] = match vault_addresses(&market_account) { + Ok(addresses) => addresses, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let oracle = match GoonfiMarket::oracle_address(&market_account) { + Ok(oracle) => oracle, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + let referenced = match self + .fetch_surfnet_accounts(params.surfnet_port, &[base_vault, quote_vault, oracle]) + .await + { + Ok(accounts) => accounts, + Err(error) => return Ok(scenario_tool_error(error)), + }; + let account = |index: usize, name: &str| { + referenced[index] + .as_ref() + .ok_or_else(|| format!("GoonFi {name} account was not found")) + }; + let (base_account, quote_account, oracle_account) = match ( + account(0, "base vault"), + account(1, "quote vault"), + account(2, "oracle"), + ) { + (Ok(base), Ok(quote), Ok(oracle)) => (base, quote, oracle), + (Err(error), ..) | (_, Err(error), _) | (.., Err(error)) => { + return Ok(scenario_tool_error(error)); + } + }; + let preparation = match build_goonfi_liquidity_scenario( + market_address, + &market_account, + base_account, + quote_account, + oracle_account, + params.base_remaining_bps.unwrap_or(0), + params.quote_remaining_bps.unwrap_or(0), + ) { + Ok(preparation) => preparation, + Err(error) => return Ok(scenario_tool_error(error.to_string())), + }; + + self.stage_scenario(preparation.scenario).await + } + #[tool( description = "Fetches ALL available override templates. MUST be called before create_scenario to get valid templateId values and property names. Constants are summarized as {label, description, optionsCount} - resolve an actual option value with search_constant_options." )] @@ -1315,6 +1530,43 @@ mod tests { }) } + #[tokio::test] + async fn goonfi_price_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_price_scenario(Parameters(CreateGoonfiPriceScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + price: "99.74".to_string(), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + + #[tokio::test] + async fn goonfi_liquidity_rejects_a_bad_market_before_any_rpc() { + let surfpool = Surfpool::new(); + let result = surfpool + .create_goonfi_liquidity_scenario(Parameters(CreateGoonfiLiquidityScenarioParams { + surfnet_port: None, + market: Some("not-a-pubkey".to_string()), + base_remaining_bps: Some(0), + quote_remaining_bps: Some(0), + })) + .await + .expect("the tool reports input errors in its payload, not as a protocol error"); + let text = format!("{:?}", result.content); + assert!( + text.contains("Invalid GoonFi market pubkey"), + "unexpected payload: {text}" + ); + } + #[tokio::test] async fn get_override_templates_summarizes_constants_instead_of_inlining_options() { let surfpool = Surfpool::new(); diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index a04ca80a1..9764d7272 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,279 @@ 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). + /// + /// `width` is the byte width of the on-chain slot field: 8, or 4 for programs that store + /// slots as u32 next to unrelated bytes. Only those two widths are accepted. + Slot { + lead: i64, + #[serde(default = "default_slot_width")] + width: usize, + }, +} + +fn default_slot_width() -> usize { + 8 +} + +impl RawEncoding { + /// Byte width of this encoding. + pub fn width(&self) -> usize { + match self { + RawEncoding::U8 => 1, + RawEncoding::U16 => 2, + RawEncoding::U32 | RawEncoding::I32 | RawEncoding::I32Strided { .. } => 4, + RawEncoding::U64 | RawEncoding::I64 => 8, + RawEncoding::Slot { width, .. } => *width, + 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, width } => { + 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) + }; + match width { + 8 => slot.to_le_bytes().to_vec(), + 4 => u32::try_from(slot) + .map_err(|_| format!("slot {slot} does not fit a 4-byte slot field"))? + .to_le_bytes() + .to_vec(), + other => return Err(format!("slot width must be 4 or 8, not {other}")), + } + } + }) + } +} + +/// 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, + /// Base58 program id that must own the account. The byte guard cannot see the owner, so + /// without this a foreign account of the same size and magic passes a raw write. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, +} + +impl RawLayout { + /// 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. + /// Rejects an account owned by the wrong program, when the layout names one. Split from + /// [`RawLayout::guard`] because the byte guard has no access to the owner. + pub fn guard_owner(&self, owner: &Pubkey) -> Result<(), String> { + let Some(required) = &self.owner else { + return Ok(()); + }; + let required = Pubkey::from_str(required) + .map_err(|e| format!("raw layout owner '{required}' is not a valid pubkey: {e}"))?; + if owner != &required { + return Err(format!( + "account owner {owner} is not the layout's program {required}" + )); + } + Ok(()) + } + + pub fn materialize( + &self, + data: &[u8], + 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 +1371,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 +1379,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 +1391,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 +1414,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 +1461,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 +1472,7 @@ impl YamlOverrideTemplate { .collect(), tags: self.tags, llm_context: self.llm_context, + raw_layout: None, } } } @@ -1241,6 +1571,244 @@ 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, width: 8 } + .encode(&json!(0), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 500); + + let bytes = RawEncoding::Slot { lead: 0, width: 8 } + .encode(&json!(-5), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 495); + + // The manifest lead is the default, used when no value is given. + let bytes = RawEncoding::Slot { lead: -1, width: 8 } + .encode(&json!(null), 500) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 499); + + // A lead that would go below zero clamps rather than wrapping. + let bytes = RawEncoding::Slot { lead: 0, width: 8 } + .encode(&json!(-10), 3) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), 0); + + // Slot is a u64. Values above i64::MAX must not wrap through a signed cast and become zero. + let large_slot = i64::MAX as u64 + 1; + let bytes = RawEncoding::Slot { lead: 0, width: 8 } + .encode(&json!(0), large_slot) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), large_slot); + + let bytes = RawEncoding::Slot { lead: 0, width: 8 } + .encode(&json!(-1), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX - 1); + + let bytes = RawEncoding::Slot { lead: 0, width: 8 } + .encode(&json!(0), u64::MAX) + .unwrap(); + assert_eq!(u64::from_le_bytes(bytes.try_into().unwrap()), u64::MAX); + + let err = RawEncoding::Slot { lead: 0, width: 8 } + .encode(&json!(1), u64::MAX) + .expect_err("a positive lead must not wrap past u64::MAX"); + assert!(err.contains("exceeds u64::MAX"), "unexpected error: {err}"); + } + + #[test] + fn slot_width_defaults_to_eight_and_narrows_to_four() { + use super::RawEncoding; + + // Manifests that spell no width keep the historical 8-byte slot bytes exactly. + let parsed: RawEncoding = serde_json::from_value(json!({"slot": {"lead": -20}})).unwrap(); + assert_eq!( + parsed, + RawEncoding::Slot { + lead: -20, + width: 8 + } + ); + assert_eq!( + parsed.encode(&json!(null), 500).unwrap(), + 480u64.to_le_bytes().to_vec() + ); + + let narrow: RawEncoding = + serde_json::from_value(json!({"slot": {"lead": 0, "width": 4}})).unwrap(); + assert_eq!(narrow.width(), 4); + assert_eq!( + narrow.encode(&json!(null), 500).unwrap(), + 500u32.to_le_bytes().to_vec() + ); + + let err = narrow + .encode(&json!(null), u64::from(u32::MAX) + 1) + .expect_err("a slot past u32::MAX must not be truncated"); + assert!(err.contains("4-byte"), "unexpected error: {err}"); + + let err = RawEncoding::Slot { lead: 0, width: 2 } + .encode(&json!(null), 500) + .expect_err("only widths 4 and 8 exist"); + assert!(err.contains("must be 4 or 8"), "unexpected error: {err}"); + } + + #[test] + fn raw_layout_owner_predicate_rejects_the_wrong_program() { + use super::{Pubkey, RawLayout}; + + let program = Pubkey::new_unique(); + let layout = RawLayout { + account_size: 32, + magic: None, + owner: Some(program.to_string()), + }; + assert!(layout.guard_owner(&program).is_ok()); + let err = layout + .guard_owner(&Pubkey::new_unique()) + .expect_err("a foreign owner must be refused"); + assert!( + err.contains("is not the layout's program"), + "unexpected error: {err}" + ); + + // No owner in the layout keeps the historical behavior: any owner passes. + let open = RawLayout { + account_size: 32, + magic: None, + owner: None, + }; + assert!(open.guard_owner(&Pubkey::new_unique()).is_ok()); + + let broken = RawLayout { + account_size: 32, + magic: None, + owner: Some("not-a-pubkey".to_string()), + }; + assert!(broken.guard_owner(&program).is_err()); + } + + #[test] + fn raw_layout_rejects_writes_past_the_end_of_the_account() { + use super::{Property, RawEncoding, RawLayout}; + + let layout = RawLayout { + account_size: 16, + magic: None, + owner: 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, + owner: 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, + owner: 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());