Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
141 changes: 141 additions & 0 deletions .github/workflows/claude-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: Perform a code review when a pull request is created.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

jobs:
claude:
# Skip drafts, and skip fork PRs: on `pull_request` they don't get the
# ANTHROPIC_API_KEY secret, so the review would only fail.
if: >-
!github.event.pull_request.draft &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
structured_output: ${{ steps.run_claude.outputs.structured_output }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
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 Claude to build and run code, install any dependencies that
# need to be downloaded before the "Run Claude" step, and add the commands
# it may run to --allowedTools below (e.g. "Bash(npm test:*)"). Any tool
# not listed there is denied.

- name: Run Claude
id: run_claude
uses: anthropics/claude-code-action@86d88e619d8e6caf07b5c3944dd14f441c533718 # v1.0.243
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Use this job's read-only token instead of the Claude GitHub App,
# which needs `id-token: write` and would get write access.
github_token: ${{ github.token }}
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 }}
git diff ${{ 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.

Return your review in two parts:
- `summary`: a short Markdown overview of the PR and your overall assessment.
- `comments`: one entry per specific issue, anchored to the line it concerns.
`path` is relative to the repository root. `line` is the line number in
the PR head version of the file (${{ github.event.pull_request.head.sha }}),
and must be a line shown in the diff above. The working tree is the merge
commit, so its line numbers can differ; check them with
`git show ${{ github.event.pull_request.head.sha }}:<path>`.
Put issues that don't belong to a single line in `summary` instead.

Pull request title and body:
----
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
claude_args: |
--allowedTools "Read,Grep,Glob,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
--json-schema '{
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Overall review as Markdown, posted as the review body."
},
"comments": {
"type": "array",
"description": "Inline comments, each anchored to one line of the PR diff.",
"items": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to the repository root." },
"line": { "type": "integer", "minimum": 1, "description": "Line number in the PR head version of the file. Must be within the diff." },
"body": { "type": "string", "description": "The comment, as Markdown." }
},
"required": ["path", "line", "body"]
}
}
},
"required": ["summary", "comments"]
}'

post_feedback:
runs-on: ubuntu-latest
needs: claude
if: needs.claude.outputs.structured_output != ''
permissions:
issues: write
pull-requests: write
steps:
- name: Report Claude feedback
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
CLAUDE_STRUCTURED_OUTPUT: ${{ needs.claude.outputs.structured_output }}
with:
github-token: ${{ github.token }}
script: |
const { summary = '', comments = [] } = JSON.parse(process.env.CLAUDE_STRUCTURED_OUTPUT);
const pr = context.payload.pull_request;
if (!summary.trim() && comments.length === 0) {
core.info('Claude returned an empty review; nothing to post.');
return;
}

try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
commit_id: pr.head.sha,
event: 'COMMENT',
body: summary.trim() || 'See inline comments.',
comments: comments.map(({ path, line, body }) => ({ path, line, side: 'RIGHT', body })),
});
} catch (error) {
// GitHub rejects the whole review if any comment points outside the
// diff. Fall back to a single PR comment so no feedback is lost.
core.warning(`Could not post inline review (${error.message}); posting a plain comment instead.`);
const details = comments.map(({ path, line, body }) => `**\`${path}:${line}\`**\n\n${body}`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: [summary.trim(), ...details].filter(Boolean).join('\n\n---\n\n'),
});
}
1 change: 1 addition & 0 deletions crates/core/src/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Protocols that are natively supported by Surfpool will have their IDLs included
- **Pump v1** - Bonding curve launchpad with curve reserve and global config override templates
- **Phoenix Eternal** - Perpetuals venue with trader collateral, mark price, maintenance margin, market fee, withdraw limit, trader capability, stop-loss trigger and delegated permission templates. See [protocols/phoenix-eternal/README.md](./protocols/phoenix-eternal/README.md)
- **PumpSwap v1** - Constant-product AMM with pool state and global config override templates, including canonical pool derivation for migrated pump.fun coins
- **Raydium AMM v4 / CLMM / CPMM** - Classic constant-product pools, concentrated-liquidity pools and CP-Swap pools, with pool state, fees, and swap stats override templates. See [protocols/raydium/v4/README.md](./protocols/raydium/v4/README.md), [protocols/raydium/v3/README.md](./protocols/raydium/v3/README.md) and [protocols/raydium/cpmm/v1/README.md](./protocols/raydium/cpmm/v1/README.md)

For custom protocols, an IDL can be registered at runtime using the [`surfnet_registerIdl`](https://docs.surfpool.run/rpc/cheatcodes#surfnet-registeridl) RPC cheatcode.

Expand Down
2 changes: 1 addition & 1 deletion crates/core/src/scenarios/protocols/kamino/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ kamino-swap-order
| Price rejected for TWAP divergence | Move the matching entry with `kamino-scope-twap`, or raise `max_twap_divergence_bps` |
| Your override silently did nothing | The field name does not exist in the IDL - surfpool logs a `warn!` and drops the whole override. Check the log |
| `exceeds what a JSON number can hold exactly` | Pass large `u128`/`i128` values as decimal strings, e.g. `"1152921504606846976000"`. Plain JSON numbers are fine below 2^53 |
| `Account with discriminator ... not found in IDL` | The account is not Anchor-based (e.g. Raydium AMM v4). It cannot be overridden through the IDL path |
| `matches no account type declared by the owner program's IDL` | No declared type has this discriminator; a type declared without one (Raydium AMM v4) also has to fill the account exactly |
| `Failed to resolve account address` | The `pubkey` is not valid base58 |
| A value the program recomputes will not stay put | Pin the input it reads instead: Scope price over a Reserve's cached price, `liquidation_threshold_pct` over the Obligation's health fields |

Expand Down
92 changes: 92 additions & 0 deletions crates/core/src/scenarios/protocols/raydium/cpmm/v1/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Raydium CPMM

Raydium's constant-product AMM without an orderbook (`raydium_cp_swap`, listed by Raydium as
CP-Swap). Two templates: a pool's status and open time, and a fee tier. For how scenarios work in
general see the [scenarios README](../../../../README.md).

**The price is not in the pool account.** A swap prices off the two vault token accounts, so move
the price with `spl-token-account-balance` on the vaults named in the pool's `token_0_vault` and
`token_1_vault` fields.

# Template index

**Raydium CPMM** &middot; `CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C`

| Template | Overrides |
|---|---|
| `raydium-cpmm-pool-state` | which operations the pool allows (`status`) and when it opens (`open_time`) |
| `raydium-cpmm-amm-config` | a fee tier, shared by every pool created on it |

## Number formats

| You'll see | It means | Example |
|---|---|---|
| `status` | bitmask of disabled operations | `4` = swaps off, `0` = everything on |
| `open_time` | unix seconds | |
| `trade_fee_rate`, `creator_fee_rate` | parts per million of the input | `2500` = 0.25% |
| `protocol_fee_rate`, `fund_fee_rate` | parts per million of the collected fee, together at most `1000000` | `120000` = 12% of the fee |

`status` bits: 0 deposit, 1 withdraw, 2 swap.

## Picking a pool

Find the pools of a pair with `getProgramAccounts` on the program, filtered by size `637` and the
two mints at offsets `168` and `200`. A pool stores the mint whose raw 32 bytes are smaller first,
so only one of the two orders returns pools. A pair can have a pool per fee tier; the one whose
vaults hold the most is usually the one you want. Raydium's API also lists pools by liquidity:
`https://api-v3.raydium.io/pools/info/list?poolType=standard&poolSortField=liquidity`. Its
`standard` type also covers AMM v4, so keep only entries whose `programId` is
`CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C`.

Set `fetchBeforeUse: true` to pull the pool from upstream first.

# Recipes

## Shock the price

A swap reads each reserve as the vault balance minus the fees the pool still owes out of it:

```
reserve_N = vault_N amount - protocol_fees_token_N - fund_fees_token_N - creator_fees_token_N
price = reserve_1 / reserve_0 x 10^(mint_0_decimals - mint_1_decimals)
```

To multiply token 0's price by `f`, write both vaults so that their product stays the same:

```
template: spl-token-account-balance # on token_0_vault
amount: <reserve_0 / sqrt(f) + owed fees of token 0>

template: spl-token-account-balance # on token_1_vault
amount: <reserve_1 x sqrt(f) + owed fees of token 1>
```

Writing one vault alone also moves the price, but changes how deep the pool is.

## Halt swaps

```
template: raydium-cpmm-pool-state
status: 4 # bit 2: swaps off, deposits and withdrawals still open
```

## Change a fee tier

```
template: raydium-cpmm-amm-config # the pool's amm_config
trade_fee_rate: 10000 # 1%
```

Every pool created on that tier shares the account, so the change reaches all of them.

# Troubleshooting

| Symptom | Fix |
|---|---|
| `NotApproved` (6000) on a swap | `status` has bit 2 set, or the cluster clock is before `open_time` |
| `InsufficientVault` (6012) on a swap | A vault was set below the fees the pool owes out of it |
| An override on the pool did not move the price | The price lives in the vaults. Use `spl-token-account-balance` |

**Known limitation.** `spl-token-account-balance` writes a token account's `amount` and keeps its
lamports. Raising a wrapped-SOL vault therefore leaves part of the new balance without lamports
behind it, and a swap that pays out more SOL than the vault held before fails in the token program.
Loading
Loading