Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
982f031
feat(scenarios): extend Kamino support across all six programs
bakasura980 Aug 13, 2026
883a01a
fix(scenarios): stop persisted overrides re-fetching the account ever…
bakasura980 Aug 13, 2026
82e0c73
Move to integration tests and fix final comments
bakasura980 Aug 14, 2026
d046a79
⏺ feat: add BisonFi override templates
bakasura980 Aug 20, 2026
686cf07
Introduce 'persist' property to the rpc and update Readme.md with lar…
bakasura980 Aug 20, 2026
783a16f
Merge branch 'feat/kamino-protocol-support' of https://github.com/Lim…
bakasura980 Aug 20, 2026
e76b994
Make the bisonfi -> BisonFi
bakasura980 Aug 24, 2026
cf7430d
feat(scenarios): extend Kamino support across all six programs
bakasura980 Aug 13, 2026
5ab5f94
fix(scenarios): stop persisted overrides re-fetching the account ever…
bakasura980 Aug 13, 2026
2a9f2f6
Move to integration tests and fix final comments
bakasura980 Aug 14, 2026
f71abb8
Introduce 'persist' property to the rpc and update Readme.md with lar…
bakasura980 Aug 20, 2026
4628245
Add workflow
bakasura980 Aug 24, 2026
a02a8cd
Move workflow to the folder
bakasura980 Aug 24, 2026
c40c4ea
Fix duplication in templates
bakasura980 Aug 24, 2026
a37f598
Merge with kamino
bakasura980 Aug 24, 2026
51de358
feat(scenarios): extend Kamino support across all six programs
bakasura980 Aug 13, 2026
76b12f1
fix(scenarios): stop persisted overrides re-fetching the account ever…
bakasura980 Aug 13, 2026
071414f
Move to integration tests and fix final comments
bakasura980 Aug 14, 2026
a512e23
Introduce 'persist' property to the rpc and update Readme.md with lar…
bakasura980 Aug 20, 2026
3e513b7
Add workflow
bakasura980 Aug 24, 2026
fe2c8a4
Move workflow to the folder
bakasura980 Aug 24, 2026
794b8e8
Fix duplication in templates
bakasura980 Aug 24, 2026
1b3323e
Fix integration tests
bakasura980 Aug 26, 2026
c7dde3c
Fix bug
bakasura980 Aug 26, 2026
77eca22
fix: stop persisted overrides re-fetching every slot
bakasura980 Aug 26, 2026
b06cd1e
fix: retry fetch_before_use after a failed remote fetch
bakasura980 Aug 26, 2026
88593fb
fix: retry fetch_before_use when the remote has no account yet
bakasura980 Aug 26, 2026
6bba69d
fix: retire fetch_before_use only once the request is met
bakasura980 Aug 26, 2026
ce2f758
fix: check slot arithmetic when scheduling overrides
bakasura980 Aug 26, 2026
61a390f
feat: make the slot encoding lead operator-supplied
bakasura980 Aug 26, 2026
26c7b5c
Merge with Kamino branch
bakasura980 Aug 26, 2026
0edf944
Merge feat/kamino-protocol-support
bakasura980 Aug 26, 2026
bf04d2a
Remove workflow
bakasura980 Aug 26, 2026
1f814bd
Merge branch 'feat/scenarios/protocols/kamino' of https://github.com/…
bakasura980 Aug 26, 2026
d243443
Merge branch 'feat/kamino-protocol-support' into feat/bisonfi-support
bakasura980 Aug 26, 2026
2b21baf
Merge feat/scenarios/protocols/kamino
bakasura980 Aug 26, 2026
b58e57b
Include the workflow
bakasura980 Aug 26, 2026
c984f57
Merge with the main branch
bakasura980 Sep 2, 2026
a633dbe
fix(types): prevent slot encoding truncation above i64 max
bakasura980 Sep 2, 2026
808922b
Merge with kamino
bakasura980 Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/openai-review.yml
Original file line number Diff line number Diff line change
@@ -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,
});
47 changes: 44 additions & 3 deletions crates/core/src/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
144 changes: 144 additions & 0 deletions crates/core/src/scenarios/protocols/bisonfi/README.md
Original file line number Diff line number Diff line change
@@ -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: <current / 10> # makes SELLING the base asset expensive
base_reserve: <current / 10> # 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: <current 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 |
Loading
Loading