diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..844c44d68 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -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 }}:`. + 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'), + }); + } diff --git a/crates/core/src/scenarios/README.md b/crates/core/src/scenarios/README.md index 65e2d5683..8229bb3ef 100644 --- a/crates/core/src/scenarios/README.md +++ b/crates/core/src/scenarios/README.md @@ -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. diff --git a/crates/core/src/scenarios/protocols/kamino/README.md b/crates/core/src/scenarios/protocols/kamino/README.md index 5a8388179..cc867bc79 100644 --- a/crates/core/src/scenarios/protocols/kamino/README.md +++ b/crates/core/src/scenarios/protocols/kamino/README.md @@ -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 | diff --git a/crates/core/src/scenarios/protocols/raydium/cpmm/v1/README.md b/crates/core/src/scenarios/protocols/raydium/cpmm/v1/README.md new file mode 100644 index 000000000..91d4ab761 --- /dev/null +++ b/crates/core/src/scenarios/protocols/raydium/cpmm/v1/README.md @@ -0,0 +1,96 @@ +# 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** · `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: + +template: spl-token-account-balance # on token_1_vault +amount: +``` + +Writing one vault alone also moves the price, but changes how deep the pool is. + +**Scenario presets → Raydium state → CPMM price shock** in Studio reads the pool when the scenario +is created and stores absolute values, so if the pool's liquidity changes before Play, create the +scenario again. + +## 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. diff --git a/crates/core/src/scenarios/protocols/raydium/cpmm/v1/idl.json b/crates/core/src/scenarios/protocols/raydium/cpmm/v1/idl.json new file mode 100644 index 000000000..0625cbfdb --- /dev/null +++ b/crates/core/src/scenarios/protocols/raydium/cpmm/v1/idl.json @@ -0,0 +1,488 @@ +{ + "address": "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", + "metadata": { + "name": "raydium_cp_swap", + "version": "0.2.0", + "spec": "0.1.0", + "description": "Raydium constant product AMM, supports Token2022 and without Openbook" + }, + "instructions": [], + "accounts": [ + { + "name": "AmmConfig", + "discriminator": [ + 218, + 244, + 33, + 104, + 203, + 203, + 43, + 111 + ] + }, + { + "name": "ObservationState", + "discriminator": [ + 122, + 174, + 197, + 53, + 129, + 9, + 165, + 132 + ] + }, + { + "name": "Permission", + "discriminator": [ + 224, + 83, + 28, + 79, + 10, + 253, + 161, + 28 + ] + }, + { + "name": "PoolState", + "discriminator": [ + 247, + 237, + 227, + 245, + 215, + 195, + 222, + 70 + ] + } + ], + "types": [ + { + "name": "AmmConfig", + "docs": [ + "Holds the current owner of the factory" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "Bump to identify PDA" + ], + "type": "u8" + }, + { + "name": "disable_create_pool", + "docs": [ + "Status to control if new pool can be create" + ], + "type": "bool" + }, + { + "name": "index", + "docs": [ + "Config index" + ], + "type": "u16" + }, + { + "name": "trade_fee_rate", + "docs": [ + "The trade fee, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "protocol_fee_rate", + "docs": [ + "The protocol fee" + ], + "type": "u64" + }, + { + "name": "fund_fee_rate", + "docs": [ + "The fund fee, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "create_pool_fee", + "docs": [ + "Fee for create a new pool" + ], + "type": "u64" + }, + { + "name": "protocol_owner", + "docs": [ + "Address of the protocol fee owner" + ], + "type": "pubkey" + }, + { + "name": "fund_owner", + "docs": [ + "Address of the fund fee owner" + ], + "type": "pubkey" + }, + { + "name": "creator_fee_rate", + "docs": [ + "The pool creator fee, denominated in hundredths of a bip (10^-6)" + ], + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "padding" + ], + "type": { + "array": [ + "u64", + 15 + ] + } + } + ] + } + }, + { + "name": "Observation", + "docs": [ + "The element of observations in ObservationState" + ], + "serialization": "bytemuckunsafe", + "repr": { + "kind": "c", + "packed": true + }, + "type": { + "kind": "struct", + "fields": [ + { + "name": "block_timestamp", + "docs": [ + "The block timestamp of the observation" + ], + "type": "u64" + }, + { + "name": "cumulative_token_0_price_x32", + "docs": [ + "the cumulative of token0 price during the duration time, Q32.32, the remaining 64 bit for overflow" + ], + "type": "u128" + }, + { + "name": "cumulative_token_1_price_x32", + "docs": [ + "the cumulative of token1 price during the duration time, Q32.32, the remaining 64 bit for overflow" + ], + "type": "u128" + } + ] + } + }, + { + "name": "ObservationState", + "serialization": "bytemuckunsafe", + "repr": { + "kind": "c", + "packed": true + }, + "type": { + "kind": "struct", + "fields": [ + { + "name": "initialized", + "docs": [ + "Whether the ObservationState is initialized" + ], + "type": "bool" + }, + { + "name": "observation_index", + "docs": [ + "the most-recently updated index of the observations array" + ], + "type": "u16" + }, + { + "name": "pool_id", + "type": "pubkey" + }, + { + "name": "observations", + "docs": [ + "observation array" + ], + "type": { + "array": [ + { + "defined": { + "name": "Observation" + } + }, + 100 + ] + } + }, + { + "name": "last_update_timestamp", + "docs": [ + "the last update timestamp" + ], + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "padding for feature update" + ], + "type": { + "array": [ + "u64", + 3 + ] + } + } + ] + } + }, + { + "name": "Permission", + "docs": [ + "Holds the current owner of the factory" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "authority", + "docs": [ + "authority" + ], + "type": "pubkey" + }, + { + "name": "padding", + "docs": [ + "padding" + ], + "type": { + "array": [ + "u64", + 30 + ] + } + } + ] + } + }, + { + "name": "PoolState", + "serialization": "bytemuckunsafe", + "repr": { + "kind": "c", + "packed": true + }, + "type": { + "kind": "struct", + "fields": [ + { + "name": "amm_config", + "docs": [ + "Which config the pool belongs" + ], + "type": "pubkey" + }, + { + "name": "pool_creator", + "docs": [ + "pool creator" + ], + "type": "pubkey" + }, + { + "name": "token_0_vault", + "docs": [ + "Token A" + ], + "type": "pubkey" + }, + { + "name": "token_1_vault", + "docs": [ + "Token B" + ], + "type": "pubkey" + }, + { + "name": "lp_mint", + "docs": [ + "Pool tokens are issued when A or B tokens are deposited.", + "Pool tokens can be withdrawn back to the original A or B token." + ], + "type": "pubkey" + }, + { + "name": "token_0_mint", + "docs": [ + "Mint information for token A" + ], + "type": "pubkey" + }, + { + "name": "token_1_mint", + "docs": [ + "Mint information for token B" + ], + "type": "pubkey" + }, + { + "name": "token_0_program", + "docs": [ + "token_0 program" + ], + "type": "pubkey" + }, + { + "name": "token_1_program", + "docs": [ + "token_1 program" + ], + "type": "pubkey" + }, + { + "name": "observation_key", + "docs": [ + "observation account to store oracle data" + ], + "type": "pubkey" + }, + { + "name": "auth_bump", + "type": "u8" + }, + { + "name": "status", + "docs": [ + "Bitwise representation of the state of the pool", + "bit0, 1: disable deposit(value is 1), 0: normal", + "bit1, 1: disable withdraw(value is 2), 0: normal", + "bit2, 1: disable swap(value is 4), 0: normal" + ], + "type": "u8" + }, + { + "name": "lp_mint_decimals", + "type": "u8" + }, + { + "name": "mint_0_decimals", + "docs": [ + "mint0 and mint1 decimals" + ], + "type": "u8" + }, + { + "name": "mint_1_decimals", + "type": "u8" + }, + { + "name": "lp_supply", + "docs": [ + "True circulating supply without burns and lock ups" + ], + "type": "u64" + }, + { + "name": "protocol_fees_token_0", + "docs": [ + "The amounts of token_0 and token_1 that are owed to the liquidity provider." + ], + "type": "u64" + }, + { + "name": "protocol_fees_token_1", + "type": "u64" + }, + { + "name": "fund_fees_token_0", + "type": "u64" + }, + { + "name": "fund_fees_token_1", + "type": "u64" + }, + { + "name": "open_time", + "docs": [ + "The timestamp allowed for swap in the pool." + ], + "type": "u64" + }, + { + "name": "recent_epoch", + "docs": [ + "recent epoch" + ], + "type": "u64" + }, + { + "name": "creator_fee_on", + "docs": [ + "Creator fee collect mode", + "0: both token_0 and token_1 can be used as trade fees. It depends on what the input token is when swapping", + "1: only token_0 as trade fee", + "2: only token_1 as trade fee" + ], + "type": "u8" + }, + { + "name": "enable_creator_fee", + "type": "bool" + }, + { + "name": "padding1", + "type": { + "array": [ + "u8", + 6 + ] + } + }, + { + "name": "creator_fees_token_0", + "type": "u64" + }, + { + "name": "creator_fees_token_1", + "type": "u64" + }, + { + "name": "padding", + "docs": [ + "padding for future updates" + ], + "type": { + "array": [ + "u64", + 28 + ] + } + } + ] + } + } + ] +} diff --git a/crates/core/src/scenarios/protocols/raydium/cpmm/v1/overrides.yaml b/crates/core/src/scenarios/protocols/raydium/cpmm/v1/overrides.yaml new file mode 100644 index 000000000..3ad5af910 --- /dev/null +++ b/crates/core/src/scenarios/protocols/raydium/cpmm/v1/overrides.yaml @@ -0,0 +1,70 @@ +protocol: Raydium +version: v0.2.0 +account_type: PoolState +idl_file_path: idl.json + +tags: + - amm + - constant-product + - defi + +llm_context: | + Raydium CPMM (CP-Swap), program CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C. + + FIND A POOL BY PAIR: resolve both mints with get_token_address, then call getProgramAccounts on the + program with filters dataSize 637 and memcmp of the two mints at offsets 168 and 200, once in each + order (only one returns pools). Read each pool's token_0_vault with dataSlice {offset 72, length 32} + and encoding base58, compare the vaults with getMultipleAccounts jsonParsed and keep the pool whose + vault holds the most; name it. If neither order returns a pool, recheck the mints once, then ask for + the pool address. A pool address the user gives is used as is. + + Set fetchBeforeUse: true. Neither template holds the price: a swap prices off the two vault token + accounts minus the fees the pool owes out of them, and the Studio price shock writes both vaults. + +templates: + - id: raydium-cpmm-pool-state + name: Override CPMM Pool State + description: Override which operations a Raydium CPMM pool allows and when it opens for swaps + idl_account_name: PoolState + properties: + - path: status + label: Pool Status + description: "Disable bitmask (u8): bit0 deposit, bit1 withdraw, bit2 swap; 0 = all enabled, 4 = swaps off" + - path: open_time + label: Open Time + description: Unix seconds; swaps fail until the cluster clock reaches it + address: + type: pubkey + llm_context: | + Put the pool in the override's account as {"pubkey":""}, never in values. + + status is a bitmask of disabled operations: 1 deposits, 2 withdrawals, 4 swaps; 4 halts swaps and 0 + enables everything. A swap on a halted pool, or before open_time (unix seconds), fails with + NotApproved (6000). + + - id: raydium-cpmm-amm-config + name: Override CPMM Fee Tier (AmmConfig) + description: Override a Raydium CPMM fee tier, shared by every pool created on it + idl_account_name: AmmConfig + properties: + - path: trade_fee_rate + label: Trade Fee Rate + description: Fee on the input amount, parts per million (2500 = 0.25%) + - path: protocol_fee_rate + label: Protocol Fee Rate + description: Protocol treasury's share of the collected trade fee, parts per million + - path: fund_fee_rate + label: Fund Fee Rate + description: Fund owner's share of the collected trade fee, parts per million + - path: creator_fee_rate + label: Creator Fee Rate + description: Extra fee on the input of pools that enable a creator fee, parts per million + address: + type: pubkey + llm_context: | + Read the pool's fee tier with getAccountInfo on the pool, dataSlice {offset 8, length 32} and + encoding base58, and put that address in the override's account. Every pool on the tier shares it. + + Rates are parts per million. trade_fee_rate and creator_fee_rate are charged on the input (2500 = + 0.25%; keep their sum below 1,000,000; the creator fee only on pools that enable it). + protocol_fee_rate and fund_fee_rate are shares of the collected fee, together at most 1,000,000. diff --git a/crates/core/src/scenarios/protocols/raydium/v3/README.md b/crates/core/src/scenarios/protocols/raydium/v3/README.md new file mode 100644 index 000000000..a7cf28472 --- /dev/null +++ b/crates/core/src/scenarios/protocols/raydium/v3/README.md @@ -0,0 +1,98 @@ +# Raydium CLMM + +Raydium's concentrated-liquidity AMM (`amm_v3`). Three templates: a pool found by its mints, a pool +found by its address, and a fee tier. For how scenarios work in general see the +[scenarios README](../../../README.md). + +# Template index + +**Raydium CLMM** · `CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK` + +| Template | Overrides | +|---|---| +| `raydium-clmm-custom` | a pool's price, active liquidity and status, selected by fee tier and mints | +| `raydium-clmm-pool-state` | the same fields, selected by the pool address | +| `raydium-clmm-amm-config` | the fee tier a pool points to, shared by every pool on it | + +## Number formats + +| You'll see | It means | Example | +|---|---|---| +| `sqrt_price_x64` | `sqrt(price_raw) x 2^64`, as a decimal **string** | | +| `liquidity` | raw `u128`, as a decimal **string** | | +| `status` | bitmask of disabled instructions | `16` = swaps off, `0` = everything on | +| `trade_fee_rate` | parts per million of the input, below `1000000` | `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 | + +`price_raw` is token_1 per token_0 in raw units; multiply by `10^(decimals_0 - decimals_1)` for the +human price. `status` bits: 0 open or increase liquidity, 1 decrease liquidity, 2 collect fees, +3 collect rewards, 4 swap. + +## Picking a pool + +Find the pools of a pair with `getProgramAccounts` on the program, filtered by size `1544` and the +two mints at offsets `73` and `105`. A pair has a pool per fee tier; the one whose vaults hold the +most is usually the one you want. For SOL/USDC that is +`3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv`, on fee tier 8. Raydium's API also lists pools by +liquidity: `https://api-v3.raydium.io/pools/info/list?poolType=concentrated&poolSortField=liquidity`. + +**Order the mints for `raydium-clmm-custom`.** `token_mint_0` is the mint whose raw 32 bytes are +numerically smaller, not the smaller base58 string. A reversed pair derives an empty address and +nothing checks it. SOL sorts before USDC. + +Set `fetchBeforeUse: true` to pull the pool from upstream first. + +# Recipes + +## Shock the price + +Use **Scenario presets → Raydium state → CLMM price shock** in Studio. It reads the pool and the +tick arrays the move crosses, and writes all three coupled fields: + +``` +template: raydium-clmm-pool-state +sqrt_price_x64: "" +tick_current: +liquidity: "" +``` + +The shock reads the pool when the scenario is created and stores absolute values, so if the pool's +liquidity changes before Play, create the scenario again. + +**Never move the price without `liquidity`.** The pool keeps the old range's depth at the new +price, and swaps succeed with wrong amounts: a +2% move on the SOL/USDC pool left it about 1.4x too +deep. Going up, a tick `t` is crossed when `old < t <= new` and its `liquidity_net` is added; going +down, when `new < t <= old` and it is subtracted. + +**The destination tick array must exist.** A swap resumes from the array covering the new tick, +PDA `["tick_array", pool, start_index as i32 big-endian]` with 60 ticks of `tick_spacing` each. +Studio refuses a move onto a missing array and names the largest (or smallest) factor that stays on +the pool's current one. + +## Freeze swaps + +``` +template: raydium-clmm-pool-state +status: 16 # bit 4: swaps off, liquidity still moves +``` + +## Change a fee tier + +A pool's tier is the AmmConfig account at its `amm_config` field; read it with `getAccountInfo`, +dataSlice `{offset 9, length 32}` and encoding base58, and override that account by pubkey: + +``` +template: raydium-clmm-amm-config # on the pool's amm_config +trade_fee_rate: 10000 # 1% +``` + +Every pool on that tier shares the account, so the change reaches all of them. + +# Troubleshooting + +| Symptom | Fix | +|---|---| +| The swap succeeds but the amounts look wrong after a price change | `liquidity` was not moved with the price. Use the Studio price shock | +| The swap fails right after a price change | The new tick has no tick array, or `sqrt_price_x64` and `tick_current` disagree | +| `raydium-clmm-custom` changed nothing | The mints are in the wrong order, so the derived address is empty | +| The swap fails on the token transfer | The paying vault holds less than the output. Fund it with `spl-token-account-balance` | diff --git a/crates/core/src/scenarios/protocols/raydium/v3/overrides.yaml b/crates/core/src/scenarios/protocols/raydium/v3/overrides.yaml index 104c79d43..643113cf6 100644 --- a/crates/core/src/scenarios/protocols/raydium/v3/overrides.yaml +++ b/crates/core/src/scenarios/protocols/raydium/v3/overrides.yaml @@ -23,8 +23,7 @@ constants: description: Best for stablecoin pairs like USDC/USDT with minimal price movement value: "0" metadata: - index: 0 - tick_spacing: 1 + tick_spacing: 10 fee_rate_bps: 1 derived_address: 4BLNHtVe942GSs4teSZqGX24xwKNkqU7bGgNn3iUiUpw - id: standard @@ -32,7 +31,6 @@ constants: description: Most common tier for pairs like SOL/USDC value: "1" metadata: - index: 1 tick_spacing: 60 fee_rate_bps: 25 derived_address: E64NGkDLLCdQ2yFNPcavaKptrEgmiQaNykUuLC1Qgwyp @@ -41,7 +39,6 @@ constants: description: For correlated assets with small price deviations value: "2" metadata: - index: 2 tick_spacing: 10 fee_rate_bps: 5 derived_address: HfERMT5DRA6C1TAqecrJQFpmkf3wsWTMncqnj3RDg5aw @@ -50,8 +47,7 @@ constants: description: For volatile or exotic token pairs value: "3" metadata: - index: 3 - tick_spacing: 100 + tick_spacing: 120 fee_rate_bps: 100 derived_address: A1BBtTYJd4i3xU8D6Tc2FzU6ZN4oXZWXKZnCxwbHXr8x - id: index_8 @@ -59,7 +55,8 @@ constants: description: The config used by the primary SOL/USDC CLMM pool value: "8" metadata: - index: 8 + tick_spacing: 1 + fee_rate_bps: 4 derived_address: 3h2e43PunVA5K34vwKCLHWhZF4aZpyaC9RmxvshGAQpL # Token mints loaded from verified tokens registry @@ -68,11 +65,27 @@ constants: description: Select a token mint address from verified tokens source: verified_tokens +llm_context: | + Raydium CLMM, program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK. + + FIND A POOL BY PAIR: resolve both mints with get_token_address, then call getProgramAccounts on the + program with filters dataSize 1544 and memcmp of the two mints at offsets 73 and 105, once in each + order (only one returns pools). Read each pool's token_vault_0 with dataSlice {offset 137, length + 32} and encoding base58, compare the vaults with getMultipleAccounts jsonParsed and keep the pool + whose vault holds the most; name it. If neither order returns a pool, recheck the mints once, then + ask for the pool address. A pool address the user gives is used as is. + + Set fetchBeforeUse: true. + + PRICE: do not move a pool's price by hand. sqrt_price_x64, tick_current and liquidity must change + together, and the new liquidity comes from the pool's tick arrays; tell the user to use the Studio + price shock instead. + templates: - id: raydium-clmm-custom - name: Override Any CLMM Pool (Custom) + name: Override CLMM Pool (By Fee Tier and Mints) description: | - Override any Raydium CLMM v3 pool by specifying the config index and token mints. + Override a Raydium CLMM pool given its fee tier index and its two token mints. Both the AMM config address AND the pool address are dynamically derived: - AMM Config: PDA from ["amm_config", config_index.to_be_bytes()] - Pool: PDA from ["pool", amm_config, token_mint_0, token_mint_1] @@ -80,39 +93,36 @@ templates: properties: - path: liquidity label: Liquidity - description: Total liquidity in the current tick range + description: Total liquidity in the current tick range (raw u128, no decimals) - path: sqrt_price_x64 label: Square Root Price (Q64.64) - description: Current price as sqrt(price) * 2^64 + description: sqrt(token_1/token_0) in Q64.64 fixed point (raw u128); must agree with tick_current - path: tick_current label: Current Tick - description: Current tick index representing the price + description: Tick index of the current price (i32); must agree with sqrt_price_x64 + - path: status + label: Pool Status + description: "Disable bitmask (u8): bit0 open/increase liquidity, bit1 decrease liquidity, bit2 collect fee, bit3 collect reward, bit4 swap; 0 = all enabled" - path: config_index type: constant_ref label: Fee Tier constant: amm_config_index + description: Fee tier of the pool. Together with the two mints it selects the pool address. - path: token_mint_0 type: constant_ref label: Token 0 Mint constant: token_mint + description: First mint of the pair as the program stores it, the lower of the two by raw bytes. - path: token_mint_1 type: constant_ref label: Token 1 Mint constant: token_mint + description: Second mint of the pair as the program stores it, the higher of the two by raw bytes. llm_context: | - CRITICAL: token_mint_0 and token_mint_1 must be SPL TOKEN MINT addresses, NOT program IDs! - - CORRECT examples of token mints: - - SOL (wrapped): So11111111111111111111111111111111111111112 - - USDC: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v - - JTO: jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL - - WRONG - these are PROGRAM IDs, not token mints: - - 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 (Raydium AMM v4) - - CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK (Raydium CLMM) - - Always use get_token_address tool to look up token mints by symbol (e.g., "SOL", "USDC", "JTO"). - The pool PDA is derived using token mints in specific order, so using wrong addresses will derive the wrong pool. + Derives the pool from config_index and the two mints, from get_token_address (never program ids). + token_mint_0 must be the mint whose raw 32 bytes are smaller (SOL before USDC); reversed mints + derive an empty address and nothing checks it. For a pool found by address, use + raydium-clmm-pool-state. address: type: pda program_id: CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK @@ -131,3 +141,53 @@ templates: value: token_mint_0 - type: property_ref value: token_mint_1 + + - id: raydium-clmm-pool-state + name: Override CLMM Pool State + description: Override a Raydium CLMM pool found by pair or by address + idl_account_name: PoolState + properties: + - path: liquidity + label: Liquidity + description: Total liquidity in the current tick range (raw u128, no decimals) + - path: sqrt_price_x64 + label: Square Root Price (Q64.64) + description: sqrt(token_1/token_0) in Q64.64 fixed point (raw u128); must agree with tick_current + - path: tick_current + label: Current Tick + description: Tick index of the current price (i32); must agree with sqrt_price_x64 + - path: status + label: Pool Status + description: "Disable bitmask (u8): bit0 open/increase liquidity, bit1 decrease liquidity, bit2 collect fee, bit3 collect reward, bit4 swap; 0 = all enabled" + llm_context: | + Put the pool in the override's account as {"pubkey":""}, never in values. + + status is a bitmask of disabled operations: 16 disables swaps, 0 enables everything. + address: + type: pubkey + + - id: raydium-clmm-amm-config + name: Override CLMM Fee Tier (AmmConfig) + description: | + Override the fee tier (AmmConfig) a Raydium CLMM pool points to. Every pool on the + tier shares the account, so the change reaches all of them. + idl_account_name: AmmConfig + properties: + - path: trade_fee_rate + label: Trade Fee Rate + description: Fee on the input amount, parts per million (2500 = 0.25%); below 1,000,000 + - path: protocol_fee_rate + label: Protocol Fee Rate + description: Protocol treasury's share of the collected trade fee, parts per million + - path: fund_fee_rate + label: Fund Fee Rate + description: Fund owner's share of the collected trade fee, parts per million + llm_context: | + Put the tier's AmmConfig in the override's account as {"pubkey":""}: read a pool's + amm_config with getAccountInfo, dataSlice {offset 9, length 32} and encoding base58. Every pool + on the tier shares the account. + + Rates are parts per million: trade_fee_rate of the input (2500 = 0.25%, below 1,000,000); + protocol_fee_rate and fund_fee_rate of the collected fee, together at most 1,000,000. + address: + type: pubkey diff --git a/crates/core/src/scenarios/protocols/raydium/v4/README.md b/crates/core/src/scenarios/protocols/raydium/v4/README.md new file mode 100644 index 000000000..c9e788a95 --- /dev/null +++ b/crates/core/src/scenarios/protocols/raydium/v4/README.md @@ -0,0 +1,85 @@ +# Raydium AMM v4 + +Raydium's classic constant-product AMM, behind most "Standard" pools. Three templates: status and +LP supply, fees, and swap counters with the open time. + +**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_coin` and +`token_pc` fields. + +# Template index + +**Raydium AMM v4** · `675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8` + +| Template | Overrides | +|---|---| +| `raydium-amm-pool-state` | whether the pool trades (`status`), its machine state and LP supply | +| `raydium-amm-fees` | the swap fee, and the trade fee the OpenBook planner uses | +| `raydium-amm-swap-stats` | lifetime swap counters, and when a scheduled pool opens | + +## Number formats + +| You'll see | It means | Example | +|---|---|---| +| `status` | which operations run, `0..=7` | `2` = disabled, `6` = swap only, `7` = waiting for `pool_open_time` | +| `fees.swap_fee_numerator` / `fees.swap_fee_denominator` | the fee on a swap's input | `25` / `10000` = 0.25%, the mainnet default | +| `out_put.pool_open_time` | unix seconds | | +| amounts and `lp_amount` | the mint's smallest unit | | + +Swaps run at `status` 1, 6 and 7. At 7 they wait for the cluster clock to pass `pool_open_time`, +then the program sets the status to 6 itself. + +## Picking a pool + +There is no catalog: pass the pool address. Find the pools of a pair with `getProgramAccounts` on +the program, filtered by size `752` and the two mints at offsets `400` (coin) and `432` (pc). A pair +can have pools in both orders, so query both. The one whose vaults hold the most is usually the one +you want; for SOL/USDC that is `58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2`. 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 CP-Swap, a different program, so keep only entries whose `programId` +is `675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8`. + +Set `fetchBeforeUse: true` to pull the pool from upstream first. + +# Recipes + +## Take a pool out of service + +``` +template: raydium-amm-pool-state +status: 2 # disabled; swaps fail with InvalidStatus +``` + +**Keep `status` in `0..=7` and `state` in `0..=6`.** The program maps both through an exhaustive +`match`, so a larger number aborts every instruction that reads the pool. + +## Make swaps expensive, or free + +``` +template: raydium-amm-fees +fees.swap_fee_numerator: 1000 +fees.swap_fee_denominator: 10000 # 10% +``` + +Set the numerator to `0` for free swaps. The `trade_fee_*` pair only prices OpenBook orders; swaps +never read it. + +## Open a scheduled pool early + +``` +template: raydium-amm-swap-stats +out_put.pool_open_time: 0 +``` + +Only matters while `status` is 7. + +# Troubleshooting + +| Symptom | Fix | +|---|---| +| Every instruction on the pool aborts | `status` is above 7 or `state` above 6 | +| `InvalidStatus` on a swap | `status` is not 1, 6 or 7 | +| A pool at status 7 still rejects swaps | The cluster clock has not reached `pool_open_time` | +| A fee change did not move the swap price | You changed `trade_fee_*`. Swaps use `swap_fee_*` | +| An override on the pool did not move the price | The price lives in the vaults. Use `spl-token-account-balance` | diff --git a/crates/core/src/scenarios/protocols/raydium/v4/generate-markets.js b/crates/core/src/scenarios/protocols/raydium/v4/generate-markets.js deleted file mode 100644 index 7f780ee02..000000000 --- a/crates/core/src/scenarios/protocols/raydium/v4/generate-markets.js +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node - -/** - * Generates and updates OpenBook market constants in overrides.yaml - * - * Usage: node generate-markets.js - * - * This script fetches AMM v4 pools from the Raydium API and updates - * the constants.openbook_market.options section in overrides.yaml directly. - */ - -const fs = require('fs'); -const path = require('path'); - -const RAYDIUM_API = 'https://api-v3.raydium.io/pools/info/list'; -const AMM_V4_PROGRAM = '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8'; -const OVERRIDES_FILE = path.join(__dirname, 'overrides.yaml'); - -// Number of top pools to include -const TOP_POOLS_COUNT = 100; - -// Minimum monthly volume to include (in USD) -const MIN_VOLUME_MONTH = 100000; // $100k - -async function fetchPools() { - const allPools = []; - let page = 1; - const pageSize = 500; - - while (true) { - // Sort by 30d volume descending - const url = `${RAYDIUM_API}?poolType=standard&poolSortField=volume30d&sortType=desc&pageSize=${pageSize}&page=${page}`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - if (!data.success || !data.data || !data.data.data) { - throw new Error('Invalid API response'); - } - - const pools = data.data.data; - if (pools.length === 0) break; - - allPools.push(...pools); - - // If we got fewer pools than requested, we've reached the end - if (pools.length < pageSize) break; - - // Safety limit - if (page >= 10) break; - - page++; - } - - return allPools; -} - -function generateId(symbolA, symbolB) { - // Normalize WSOL to SOL for the ID - const normA = symbolA === 'WSOL' ? 'SOL' : symbolA; - const normB = symbolB === 'WSOL' ? 'SOL' : symbolB; - return `${normA.toLowerCase()}-${normB.toLowerCase()}`; -} - -function generateLabel(symbolA, symbolB) { - // Normalize WSOL to SOL for display - const normA = symbolA === 'WSOL' ? 'SOL' : symbolA; - const normB = symbolB === 'WSOL' ? 'SOL' : symbolB; - return `${normA}/${normB}`; -} - -function generateOptionsYaml(pools) { - const lines = []; - - for (const pool of pools) { - // Trim whitespace from symbols - some tokens have trailing spaces - const symbolA = pool.mintA.symbol.trim(); - const symbolB = pool.mintB.symbol.trim(); - const id = generateId(symbolA, symbolB); - const label = generateLabel(symbolA, symbolB); - const marketId = pool.marketId; - - lines.push(` - id: "${id}"`); - lines.push(` label: "${label}"`); - lines.push(` value: "${marketId}"`); - lines.push(` description: "${label} AMM v4 pool"`); - } - - return lines.join('\n'); -} - -async function main() { - console.log('Fetching AMM v4 pools from Raydium API...'); - - const allPools = await fetchPools(); - console.log(`Found ${allPools.length} total standard pools`); - - // Filter for AMM v4 pools with OpenBook markets - const ammV4Pools = allPools.filter(pool => { - // Must be AMM v4 program - if (pool.programId !== AMM_V4_PROGRAM) return false; - // Must have OpenBook market - if (!pool.marketId) return false; - // Must have sufficient monthly volume - const volumeMonth = pool.month?.volume || 0; - if (volumeMonth < MIN_VOLUME_MONTH) return false; - return true; - }); - - console.log(`Found ${ammV4Pools.length} AMM v4 pools with OpenBook markets and sufficient volume`); - - // Sort by monthly volume (highest first) - ammV4Pools.sort((a, b) => { - const volA = a.month?.volume || 0; - const volB = b.month?.volume || 0; - return volB - volA; - }); - - // Take top pools by volume - const topPools = ammV4Pools.slice(0, TOP_POOLS_COUNT); - console.log(`Using top ${topPools.length} pools by 30d volume`); - - // Read existing overrides.yaml - console.log(`Reading ${OVERRIDES_FILE}...`); - const yamlContent = fs.readFileSync(OVERRIDES_FILE, 'utf8'); - - // Update the YAML file with new constants - const newYamlContent = updateYamlFile(yamlContent, topPools); - - // Write updated yaml - fs.writeFileSync(OVERRIDES_FILE, newYamlContent); - console.log(`\nUpdated ${OVERRIDES_FILE} with ${topPools.length} OpenBook markets`); -} - -function generateConstantsSection(pools) { - const optionsYaml = generateOptionsYaml(pools); - return `constants: - openbook_market: - label: OpenBook Market - description: Select an OpenBook market for the AMM v4 pool - options: -${optionsYaml}`; -} - -function updateYamlFile(yamlContent, pools) { - // Find the templates section - const templatesIndex = yamlContent.indexOf('\ntemplates:'); - if (templatesIndex === -1) { - throw new Error('Could not find templates section in overrides.yaml'); - } - - // Find the start of the file before any constants (or before templates if no constants) - const constantsIndex = yamlContent.indexOf('\nconstants:'); - - let beforeSection; - if (constantsIndex !== -1 && constantsIndex < templatesIndex) { - // There's an existing constants section - take everything before it - beforeSection = yamlContent.substring(0, constantsIndex); - } else { - // No constants section - take everything before templates - beforeSection = yamlContent.substring(0, templatesIndex); - } - - // Get the templates section and everything after - const templatesSection = yamlContent.substring(templatesIndex); - - // Generate the new constants section - const constantsSection = generateConstantsSection(pools); - - // Combine: before + constants + blank line + templates - return beforeSection + '\n' + constantsSection + '\n' + templatesSection; -} - -main().catch(err => { - console.error('Error:', err.message); - process.exit(1); -}); diff --git a/crates/core/src/scenarios/protocols/raydium/v4/idl.json b/crates/core/src/scenarios/protocols/raydium/v4/idl.json index 8ff4b58d1..820c308d3 100644 --- a/crates/core/src/scenarios/protocols/raydium/v4/idl.json +++ b/crates/core/src/scenarios/protocols/raydium/v4/idl.json @@ -10,15 +10,11 @@ "accounts": [ { "name": "AmmInfo", - "discriminator": [0, 0, 0, 0, 0, 0, 0, 0] + "discriminator": [] }, { "name": "TargetOrders", - "discriminator": [0, 0, 0, 0, 0, 0, 0, 1] - }, - { - "name": "Fees", - "discriminator": [0, 0, 0, 0, 0, 0, 0, 2] + "discriminator": [] } ], "types": [ diff --git a/crates/core/src/scenarios/protocols/raydium/v4/overrides.yaml b/crates/core/src/scenarios/protocols/raydium/v4/overrides.yaml index 28e6ba502..37a2d118d 100644 --- a/crates/core/src/scenarios/protocols/raydium/v4/overrides.yaml +++ b/crates/core/src/scenarios/protocols/raydium/v4/overrides.yaml @@ -8,510 +8,97 @@ tags: - constant-product - defi -constants: - openbook_market: - label: OpenBook Market - description: Select an OpenBook market for the AMM v4 pool - options: - - id: "sol-pippin" - label: "SOL/pippin" - value: "4Zm2A7VUfuvudpVpQkJyrg2DiZTcw6ckwbKP9LvizAkx" - description: "SOL/pippin AMM v4 pool" - - id: "sol-fartcoin" - label: "SOL/Fartcoin" - value: "HLBdJzA7KE4zxT51opWj2tVfCayB4iZH2RZ18MqKCgNM" - description: "SOL/Fartcoin AMM v4 pool" - - id: "sol-alch" - label: "SOL/ALCH" - value: "9CnPBMw348zAePQG1NKSVPJ7MZRzuqygsNvYZ2KkPRx2" - description: "SOL/ALCH AMM v4 pool" - - id: "sol-jellyjelly" - label: "SOL/jellyjelly" - value: "3ifx5PeA2S8shK12botPzW5nNtuH8SPqsc2aRcRLevCE" - description: "SOL/jellyjelly AMM v4 pool" - - id: "sol-arc" - label: "SOL/arc" - value: "GGAGxzMcwQYAj8yV39oLHGtCKUm7rHGTdGZDkmi3Qsqt" - description: "SOL/arc AMM v4 pool" - - id: "sol-fwog" - label: "SOL/FWOG" - value: "J1q31fGsvfdhewKey4Lxqc568y2HQZoJWYCqqWs966qM" - description: "SOL/FWOG AMM v4 pool" - - id: "sol-moodeng" - label: "SOL/MOODENG" - value: "7ZSzvJ6fcaLRmrWwbPXXGR759Fr8a7MCS52gfivWPGYQ" - description: "SOL/MOODENG AMM v4 pool" - - id: "popcat-sol" - label: "POPCAT/SOL" - value: "92R9ZDC7buk2gQ8kHVynnSAxKjHYGUippr9QUdZ759iF" - description: "POPCAT/SOL AMM v4 pool" - - id: "sol-zerebro" - label: "SOL/ZEREBRO" - value: "6KoCGBfB8w8kV55doVLhHMmPobEAHeHSftNfLfHDXs86" - description: "SOL/ZEREBRO AMM v4 pool" - - id: "sol-usdc" - label: "SOL/USDC" - value: "8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6" - description: "SOL/USDC AMM v4 pool" - - id: "giga-sol" - label: "GIGA/SOL" - value: "7apBzdAi3Bb35emBiChZHrrVMtEuQLhnPCVPG5afG3qm" - description: "GIGA/SOL AMM v4 pool" - - id: "sol-griffain" - label: "SOL/GRIFFAIN" - value: "2su6pnFyc6uGGwJKuyLsxU1QysidBEV21Xitp2hzPnAQ" - description: "SOL/GRIFFAIN AMM v4 pool" - - id: "bome-sol" - label: "BOME/SOL" - value: "58sHobBa2KmyE3EKxCpgxn5KGuzudmGHsgqYgrfciyzd" - description: "BOME/SOL AMM v4 pool" - - id: "sol-usd1" - label: "SOL/USD1" - value: "CV2sfgbG8eM6dJe3sozjtRoeRF2oCik5VhMhn7n2niWT" - description: "SOL/USD1 AMM v4 pool" - - id: "mew-sol" - label: "MEW/SOL" - value: "CujsiQUzNUPKTADPQ67XKJKbaVo4KpzEXvNPp1gXzQKH" - description: "MEW/SOL AMM v4 pool" - - id: "$wif-sol" - label: "$WIF/SOL" - value: "EzMCG3oJpXu2enNAh2A18iSg6Giq3Bt1wPDFDDiYgxeY" - description: "$WIF/SOL AMM v4 pool" - - id: "ray-usdc" - label: "RAY/USDC" - value: "DZjbn4XC8qoHKikZqzmhemykVzmossoayV9ffbsUqxVj" - description: "RAY/USDC AMM v4 pool" - - id: "spx-sol" - label: "SPX/SOL" - value: "AaSc4KZT7Zq9d9KdZAxuAYmgUojdowxXRc2aD3TgFkmG" - description: "SPX/SOL AMM v4 pool" - - id: "sol-swarms" - label: "SOL/swarms" - value: "5N2CczkF7ynQZLg1tFZvbMxAFFLW6v9CWfsVf24JLVY" - description: "SOL/swarms AMM v4 pool" - - id: "sol-aura" - label: "SOL/aura" - value: "3DGZraVHWYutzhs1eoxdarov8Qj5ijUyASY9XkZ3H5V1" - description: "SOL/aura AMM v4 pool" - - id: "sol-ace" - label: "SOL/ACE" - value: "BYZ3aK6tEKnTSKFWRJWu3UzXp8NQyAJEiicuSjrHi79q" - description: "SOL/ACE AMM v4 pool" - - id: "sol-vine" - label: "SOL/VINE" - value: "7bpb6bK3asNrSAfiRq1LZTKD1pfxnhYZugaWEXpE4H99" - description: "SOL/VINE AMM v4 pool" - - id: "sol-act" - label: "SOL/ACT" - value: "Em8UBZq6sBShpqEg3eKVkC3jnELLVEdvnmMppM11Bto4" - description: "SOL/ACT AMM v4 pool" - - id: "sol-dreams" - label: "SOL/dreams" - value: "AFKYSL1SeXfLjDpHyZYT5YaSwjpxr5aEHNXfp7DT9j4t" - description: "SOL/dreams AMM v4 pool" - - id: "sol-pnut" - label: "SOL/Pnut" - value: "DPAkA5GRPVZMGdw2wFvmSAnm8uAFi3vz9gwyFMUiJ4uB" - description: "SOL/Pnut AMM v4 pool" - - id: "sol-chillguy" - label: "SOL/CHILLGUY" - value: "8vHJ2BVaQu7VcshPQEp53MtR54y2o4RyPpyaMgoTyn28" - description: "SOL/CHILLGUY AMM v4 pool" - - id: "sol-shoggoth" - label: "SOL/Shoggoth" - value: "DHo6XDS6sxfKNPhxiZfksrf1GDaDw1DtcyFB7MyjZ4hQ" - description: "SOL/Shoggoth AMM v4 pool" - - id: "sol-bert" - label: "SOL/Bert" - value: "6HKGf6iTAoKqCe1x7LEkV6ugVT6SEWU3w8K6WEpq9bno" - description: "SOL/Bert AMM v4 pool" - - id: "rizzmas-sol" - label: "Rizzmas/SOL" - value: "3145TL8H59aUHKmnargJzwSboyb4uEkDZpTHmQFu6nqM" - description: "Rizzmas/SOL AMM v4 pool" - - id: "sol-ava" - label: "SOL/AVA" - value: "Gy1q9M4PHr5F74c5c9QyqZ8rYVXqpvkWUf6DsmqXjFjE" - description: "SOL/AVA AMM v4 pool" - - id: "sol-goat" - label: "SOL/GOAT" - value: "D8Y5M3n1Q12kfXavoQZrdCTnJbs6ohTxCQqZiJZiQTnQ" - description: "SOL/GOAT AMM v4 pool" - - id: "ray-sol" - label: "RAY/SOL" - value: "C6tp2RVZnxBPFbnAsfTjis8BN9tycESAT4SgDQgbbrsA" - description: "RAY/SOL AMM v4 pool" - - id: "sol-italianrot" - label: "SOL/Italianrot" - value: "7PPS8ZrM9ABstx8VUgmdq11PjGQ15r7aD1EyWmAbTa8Y" - description: "SOL/Italianrot AMM v4 pool" - - id: "usdc-$myro" - label: "USDC/$MYRO" - value: "D1EbH21mK1PiHterpcNK5HQFMBBhm5pTQAFJYukR1aeN" - description: "USDC/$MYRO AMM v4 pool" - - id: "popcat-usdc" - label: "POPCAT/USDC" - value: "5wchoBr91tdDpK4qTMFhhaYh4YGQ3t22G7LYN9ugQ7tZ" - description: "POPCAT/USDC AMM v4 pool" - - id: "sol-payai" - label: "SOL/PAYAI" - value: "9iuZBH489J9oqZouRkXoTvhxdYLnBZVeZq6rDzngPMto" - description: "SOL/PAYAI AMM v4 pool" - - id: "hnt-sol" - label: "HNT/SOL" - value: "5XRc8PY76VsK7x2KAr7a3j1NoNBxPTVRf8oHRnr2gJcz" - description: "HNT/SOL AMM v4 pool" - - id: "sol-ban" - label: "SOL/Ban" - value: "ESxe3UdJLjW2eWzdVdrbpCjjvH17Z5Bhe9SMmvGKTAcs" - description: "SOL/Ban AMM v4 pool" - - id: "nyan-sol" - label: "NYAN/SOL" - value: "8LhcZjVYNKK5mYdYQJjFVav5PjvDDEqHUHAZbJLE1Fya" - description: "NYAN/SOL AMM v4 pool" - - id: "sol-pythia" - label: "SOL/PYTHIA" - value: "4gfemrp5oTL4sobeJr349v5jJQcutFuGDQdqCzzuS8n1" - description: "SOL/PYTHIA AMM v4 pool" - - id: "ray-usdt" - label: "RAY/USDT" - value: "GpHbiJJ9VHiuHVXeoet121Utrbm1CSNNzYrBKB8Xz2oz" - description: "RAY/USDT AMM v4 pool" - - id: "wf-wfai" - label: "WF/WFAI" - value: "8euZXvbFwAe95mSj2utb1wLapKx9fy46vZ3FEDhDG7Pt" - description: "WF/WFAI AMM v4 pool" - - id: "sol-concho" - label: "SOL/CONCHO" - value: "75PgKpxPMB5DanaHvFMGh47QUhY2mfDjzC8mAX3swRpW" - description: "SOL/CONCHO AMM v4 pool" - - id: "sol-usdt" - label: "SOL/USDT" - value: "2AdaV97p6SfkuMQJdu8DHhBhmJe7oWdvbm52MJfYQmfA" - description: "SOL/USDT AMM v4 pool" - - id: "ponke-sol" - label: "PONKE/SOL" - value: "4KVf81FA4R79fKtxHJPWBRhgZpfDsihabJxPxgYbe2Gb" - description: "PONKE/SOL AMM v4 pool" - - id: "nub-sol" - label: "nub/SOL" - value: "CwCfophk9jJp27i8C6bj5M5hCQSsFWTouDwkJ26xRVtu" - description: "nub/SOL AMM v4 pool" - - id: "sol-cat" - label: "SOL/CAT" - value: "9c5Eg8Fm77fbFgG3FgWmru6NZapMDr83hsJGcjJnCXd6" - description: "SOL/CAT AMM v4 pool" - - id: "sol-pp" - label: "SOL/PP" - value: "7SLnWeRe9YnoR52KKDpYJrNvFm4pjqNU3sKELm2874FG" - description: "SOL/PP AMM v4 pool" - - id: "fartboy-sol" - label: "FARTBOY/SOL" - value: "8pTw8nTRB9e11p69G8AHoa1EREvcWrdgohQ6hK2NVGHY" - description: "FARTBOY/SOL AMM v4 pool" - - id: "sol-snai" - label: "SOL/SNAI" - value: "4ZcEVncNnTSQDgiC3zqDs5Y19hNSDj2EdLfSTPz5qiZo" - description: "SOL/SNAI AMM v4 pool" - - id: "sol-pvs" - label: "SOL/PVS" - value: "GqUYy7vCpsCYEgXv1fcxtx992dk3JHQrb9TyyVdwxn5f" - description: "SOL/PVS AMM v4 pool" - - id: "roi-usd1" - label: "ROI/USD1" - value: "psoMyZk67fP6bdnzxPmurFuvaA41kyUfJieeozg7tEY" - description: "ROI/USD1 AMM v4 pool" - - id: "sol-retire" - label: "SOL/retire" - value: "8A5rMYWmWjAPSQpMUxCZyvvCmLrjNiHqpJMfEufvvndX" - description: "SOL/retire AMM v4 pool" - - id: "lode-sol" - label: "LODE/SOL" - value: "GqznZ1PKHj6V845TtdmiPsV4P9CdaU7KCTicaVmZrvBf" - description: "LODE/SOL AMM v4 pool" - - id: "trump-sol" - label: "TRUMP/SOL" - value: "6Qa2vpQyRGrZBTDs7vZymP2hRReCVBSespBSYBrArUrZ" - description: "TRUMP/SOL AMM v4 pool" - - id: "coral-sol" - label: "CORAL/SOL" - value: "22hLVzWhKiRLa4CbFmXFui31Hj6K78sAQSM7vuFrFRmb" - description: "CORAL/SOL AMM v4 pool" - - id: "sol-moby" - label: "SOL/MOBY" - value: "D6mky3dMTH2n8ReKs8rJWiC4dExGBvVgf6cXCyKRpmeC" - description: "SOL/MOBY AMM v4 pool" - - id: "sol-ufd" - label: "SOL/UFD" - value: "7x6KvUhUjQ9KWKhGu9VXvoj1n9NV1XW4sbdZKfE3YzFE" - description: "SOL/UFD AMM v4 pool" - - id: "sol-bop" - label: "SOL/bop" - value: "6RYzckuc3Wyczh1JZdLFrtp1oaiMDFHaWaSQAMu7Kh7b" - description: "SOL/bop AMM v4 pool" - - id: "sol-allindoge" - label: "SOL/ALLINDOGE" - value: "3hsPp3ivbdbiaVxtgceE9n4XAb4TXvxx5zqkt7TvTBzF" - description: "SOL/ALLINDOGE AMM v4 pool" - - id: "hola-sol" - label: "HOLA/SOL" - value: "FJNV4TRY1BtWLFotsgheGdcoCHMFALFBKPixsY4qDJzM" - description: "HOLA/SOL AMM v4 pool" - - id: "sol-san" - label: "SOL/San" - value: "HQTTQdu1KKqH1NiLyboJRjnhXXN8x5Mo4HiFs3Sc2EKj" - description: "SOL/San AMM v4 pool" - - id: "sol-$collat" - label: "SOL/$COLLAT" - value: "5rvYEQyZsom3jUoGZ2QWFXNCashW1x15bZ2k5fyE69VT" - description: "SOL/$COLLAT AMM v4 pool" - - id: "sol-shibu" - label: "SOL/SHIBU" - value: "B2hEQPD9h6k6Qrq2rFdygGfW9k3sAoX4hjSDXPSkFs8b" - description: "SOL/SHIBU AMM v4 pool" - - id: "tai-sol" - label: "TAI/SOL" - value: "BBvyCcQXV8yhtdsM2pdonVE392LK4xT9eHUjMn4zZXpX" - description: "TAI/SOL AMM v4 pool" - - id: "sol-$michi" - label: "SOL/$michi" - value: "C5RUxJuZz5YC7s9zKiLrXePs9WxWNyJPY7jRhBe76fiJ" - description: "SOL/$michi AMM v4 pool" - - id: "sol-labubu" - label: "SOL/LABUBU" - value: "2L9z9t2B1aBAF9UYisXcNUgccJH3RMvtbZWzZpWrj3KN" - description: "SOL/LABUBU AMM v4 pool" - - id: "nos-usdc" - label: "NOS/USDC" - value: "8XaEfk3TURqgrJZvdJWrPLqyXSCmJ9MfSwZrkaYmsG7r" - description: "NOS/USDC AMM v4 pool" - - id: "retardio-sol" - label: "RETARDIO/SOL" - value: "9H9JtEzHPJqseNWjWiPeYZ1JqUTNKbx3whva27VTqBNQ" - description: "RETARDIO/SOL AMM v4 pool" - - id: "sol-avb" - label: "SOL/AVB" - value: "3Lzzft9ahF3Yr29eCWQ2L7y2j5MTaMzE3ax9szaMFnNx" - description: "SOL/AVB AMM v4 pool" - - id: "sol-dora" - label: "SOL/DORA" - value: "HztnK84FejxLJ1d2m3L9CNPh83kA4gg7SicvbRLoZwzb" - description: "SOL/DORA AMM v4 pool" - - id: "sol-buzz" - label: "SOL/BUZZ" - value: "B1QWDnTgq6YyocNWwm31tGd1GmsDj6V4dbrkLPwTzfnC" - description: "SOL/BUZZ AMM v4 pool" - - id: "pippin-sol" - label: "pippin/SOL" - value: "CMXs7d4Ato7jbLbtWouNuLycnMNjFdwj8YWWpbifYPDQ" - description: "pippin/SOL AMM v4 pool" - - id: "sol-qst" - label: "SOL/QST" - value: "fV9tXHK8wbBbLRme4a9yeWRf2YE3gCjHrrEkPtaxvbk" - description: "SOL/QST AMM v4 pool" - - id: "sol-looksmax" - label: "SOL/LOOKSMAX" - value: "HLMSDP448g5jSWMDRFi1sm5KRkvyrzNHXTc42aFmK45E" - description: "SOL/LOOKSMAX AMM v4 pool" - - id: "sol-daddy" - label: "SOL/DADDY" - value: "FvafJSu7dMFuXyzGU6A3k4k8hLmRYCnzitr7yMaaKQsU" - description: "SOL/DADDY AMM v4 pool" - - id: "sol-$1" - label: "SOL/$1" - value: "7vXXq1ipGZisWpZGEu1W8WKw4snv5DGCRXZWFNhw5TLs" - description: "SOL/$1 AMM v4 pool" - - id: "sol-wojak" - label: "SOL/WOJAK" - value: "6mJP7xFeeoF8BJWvkSz5AqoAohkwuSS8otkEabCCtsDa" - description: "SOL/WOJAK AMM v4 pool" - - id: "rizo-sol" - label: "RIZO/SOL" - value: "FuhfZARKPCzpxSJBJJGoRvjywyx9w17E7io7xeoeLX8T" - description: "RIZO/SOL AMM v4 pool" - - id: "sol-hehe" - label: "SOL/hehe" - value: "4dQr9noPevCMgy1o1stxUrZZZdQwWFk1ShfmrV9w88cd" - description: "SOL/hehe AMM v4 pool" - - id: "chex-sol" - label: "CHEX/SOL" - value: "8B6ozGVeTzB6fnvFgNgust24TMn4WFcV7LnN1ZTsUY8i" - description: "CHEX/SOL AMM v4 pool" - - id: "sol-zara" - label: "SOL/ZARA" - value: "PJcY7oW1fbzdJqWkAvEsb5xVXKEJPTBRWjMf2da5tb3" - description: "SOL/ZARA AMM v4 pool" - - id: "hosico-sol" - label: "Hosico/SOL" - value: "8sgJx1bigxapjgcpmvu3X2TwrinzWbRNVsRE2R6E2YoH" - description: "Hosico/SOL AMM v4 pool" - - id: "lockin-sol" - label: "LOCKIN/SOL" - value: "H3W1wXRGnAAopkqxW1iDHAKxCEKPmv8oR3snq2QLYCB4" - description: "LOCKIN/SOL AMM v4 pool" - - id: "sol-would" - label: "SOL/WOULD" - value: "AHeC3C7h1D1TmNM3omGk69Q9JgiCY1PBqYFNm1CWevNu" - description: "SOL/WOULD AMM v4 pool" - - id: "sol-titcoin" - label: "SOL/titcoin" - value: "Hqg15ZSWB5VFCEr5puQQJ2rEGHhzkAfmh2FzzoxJvEcS" - description: "SOL/titcoin AMM v4 pool" - - id: "sol-sigma" - label: "SOL/SIGMA" - value: "4DrR2aow56rkMN5boox6q6nEmnGKcLsYnTbMAhiw2oRd" - description: "SOL/SIGMA AMM v4 pool" - - id: "oza-sol" - label: "OZA/SOL" - value: "A4JEnz9LBm8SYgkfd6rGW5q7hLN9C6FtVjms7vwBpM7x" - description: "OZA/SOL AMM v4 pool" - - id: "usa-sol" - label: "USA/SOL" - value: "4f6LoHhZNQdYERu7gzvvhX13ugyyKotJPyC7jaVBLUHG" - description: "USA/SOL AMM v4 pool" - - id: "gme-sol" - label: "GME/SOL" - value: "J2UaGXg2YECx3GFqKVEsDqP1yZrtwVcY1gXPPLdXAodj" - description: "GME/SOL AMM v4 pool" - - id: "sol-a47" - label: "SOL/A47" - value: "YxCsbdAdfE48kW7QFJPA446dBp3qRLzMSLxpF8nBhXk" - description: "SOL/A47 AMM v4 pool" - - id: "sol-usa" - label: "SOL/USA" - value: "85PkNRCSfLhh1pPn1SHc6DFLcFkuk71HPLi18ntPPJPy" - description: "SOL/USA AMM v4 pool" - - id: "sol-pwease" - label: "SOL/pwease" - value: "A3kVzzqzpa3Xm3W2cMbjyZRpdRhLcp7qUwDqSC5yRisN" - description: "SOL/pwease AMM v4 pool" - - id: "sol-nuit" - label: "SOL/nuit" - value: "6ySxHjNAN4rCeL38sV8e4njyxkuEcaeVfSmkH4rzEofb" - description: "SOL/nuit AMM v4 pool" - - id: "sol-brew" - label: "SOL/BREW" - value: "HJx4gdWrtRTTmk9aiHqE4uw9WWtFManRjoUFNiD5WPwD" - description: "SOL/BREW AMM v4 pool" - - id: "sol-agialpha" - label: "SOL/AGIALPHA" - value: "7ieTijVeoSH7cvLpVqa78Rgb4WjYNpPJzkKcRfxbNEy3" - description: "SOL/AGIALPHA AMM v4 pool" - - id: "sol-stupid" - label: "SOL/Stupid" - value: "2W1sWeZkLLsJUtbbazm4Ri6V4Wnt1FPVd36xCf6vKGax" - description: "SOL/Stupid AMM v4 pool" - - id: "sol-mini" - label: "SOL/mini" - value: "GVMV3gZTs7AKCLwrV6iXErLbeNwTgXNcMfHPjHoCUmfs" - description: "SOL/mini AMM v4 pool" - - id: "atlas-ray" - label: "ATLAS/RAY" - value: "Bn7n597jMxU4KjBPUo3QwJhbqr5145cHy31p6EPwPHwL" - description: "ATLAS/RAY AMM v4 pool" - - id: "sol-cupsey" - label: "SOL/Cupsey" - value: "9Gp7cqMfpTmUZyjLf58UyoqQwV3AH9mieFcBextTQcET" - description: "SOL/Cupsey AMM v4 pool" +llm_context: | + Raydium AMM v4, program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8. Pools are keypair accounts + (AmmInfo, 752 bytes); put the pool in the override's account as {"pubkey":""}, never in + values. + + FIND A POOL BY PAIR: resolve both mints with get_token_address, then call getProgramAccounts on the + program with filters dataSize 752 and memcmp of the two mints at offsets 400 and 432, once in each + order (a pair can have pools in both). Read each pool's vault of the first mint with dataSlice + {offset 336, length 32} and encoding base58 (368 in the second order), compare the vaults with + getMultipleAccounts jsonParsed and keep the pool whose vault holds the most; name it. If neither + order returns a pool, recheck the mints once, then ask for the pool address. A pool address the user + gives is used as is. + + Set fetchBeforeUse: true. The price is not in this account: a swap prices off the vault token + accounts token_coin and token_pc, which spl-token-account-balance overrides. templates: - id: raydium-amm-pool-state name: Override AMM Pool State - description: Override Raydium AMM v4 pool state including status and configuration + description: Override a Raydium AMM v4 pool's trading status, machine state and LP supply idl_account_name: AmmInfo - properties: ["status", "state", "lp_amount"] + properties: + - path: status + label: Pool Status + description: >- + Which operations the program allows: 0 uninitialized, 1 initialized, 2 disabled, + 3 withdraw only, 4 liquidity only, 5 orderbook only, 6 swap only, 7 waiting trade + - path: state + label: Machine State + description: >- + Orderbook state machine position: 0 invalid, 1 idle, 2 cancel all orders, + 3 plan orders, 4 cancel order, 5 place orders, 6 purge order + - path: lp_amount + label: LP Supply + description: LP tokens the pool has minted, in raw units of the LP mint + llm_context: | + status (u64): swaps run at 1 (initialized), 6 (swap only) and 7 (waiting trade, once the cluster + clock passes out_put.pool_open_time). 2 disabled, 3 withdraw only, 4 liquidity only and 5 orderbook + only reject swaps with InvalidStatus. Keep status in 0..=7 and state in 0..=6; a larger value aborts + every instruction on the pool. lp_amount sizes deposits only. address: type: pubkey - id: raydium-amm-fees name: Override AMM Fees - description: Override Raydium AMM v4 fee configuration for swap simulations + description: Override a Raydium AMM v4 pool's swap and trade fee rates idl_account_name: AmmInfo properties: - [ - "fees.swap_fee_numerator", - "fees.swap_fee_denominator", - "fees.trade_fee_numerator", - "fees.trade_fee_denominator", - ] + - path: fees.swap_fee_numerator + label: Swap Fee Numerator + description: Numerator of the fee every swap charges on its input amount + - path: fees.swap_fee_denominator + label: Swap Fee Denominator + description: Denominator of the swap fee; 25/10000 is the mainnet default of 0.25% + - path: fees.trade_fee_numerator + label: Trade Fee Numerator + description: Numerator of the fee the OpenBook order planner prices its orders with + - path: fees.trade_fee_denominator + label: Trade Fee Denominator + description: Denominator of the trade fee; 25/10000 is the mainnet default of 0.25% + llm_context: | + Each fee is a numerator over a denominator; override both of a pair together. Swaps charge + ceil(amount_in * swap_fee_numerator / swap_fee_denominator) and read only fees.swap_fee_*; the + trade_fee_* pair prices OpenBook orders. The mainnet default is 25/10000 = 0.25%; a numerator of 0 + makes swaps free. address: type: pubkey - id: raydium-amm-swap-stats name: Override AMM Swap Statistics - description: Override Raydium AMM v4 swap statistics and cumulative amounts + description: Override a Raydium AMM v4 pool's cumulative swap counters and open time idl_account_name: AmmInfo properties: - [ - "out_put.swap_coin_in_amount", - "out_put.swap_pc_out_amount", - "out_put.swap_pc_in_amount", - "out_put.swap_coin_out_amount", - "out_put.pool_open_time", - ] - address: - type: pubkey - - - id: raydium-amm-custom - name: Override Custom AMM Pool - description: | - Override any Raydium AMM v4 pool by specifying the OpenBook market address. - AMM v4 pools are integrated with OpenBook orderbook, so the pool PDA is derived from the market address. - PDA seeds: [program_id, openbook_market, "amm_associated_seed"] - idl_account_name: AmmInfo - properties: - - path: status - label: Pool Status - description: Pool operational status - - path: state - label: Pool State - description: Pool state flags - - path: lp_amount - label: LP Amount - description: Total LP tokens in circulation - - path: fees.swap_fee_numerator - label: Swap Fee Numerator - description: Swap fee numerator for fee calculation - - path: fees.swap_fee_denominator - label: Swap Fee Denominator - description: Swap fee denominator for fee calculation + - path: out_put.swap_coin_in_amount + label: Cumulative Coin In + description: Running total of coin taken in by swaps, in raw units of the coin mint + - path: out_put.swap_pc_out_amount + label: Cumulative PC Out + description: Running total of pc paid out by swaps, in raw units of the pc mint + - path: out_put.swap_pc_in_amount + label: Cumulative PC In + description: Running total of pc taken in by swaps, in raw units of the pc mint + - path: out_put.swap_coin_out_amount + label: Cumulative Coin Out + description: Running total of coin paid out by swaps, in raw units of the coin mint - path: out_put.pool_open_time label: Pool Open Time - description: Unix timestamp when pool opened - - path: market - type: constant_ref - label: OpenBook Market - constant: openbook_market + description: Unix timestamp from which a pool with status 7 accepts swaps llm_context: | - CRITICAL: Always set fetchBeforeUse: true for Raydium AMM v4 pools! - This fetches the current account data from mainnet before applying your overrides. - - HOW TO USE THIS TEMPLATE: - 1. Select the correct OpenBook market from the `market` constant options - 2. The pool PDA is automatically derived from: [program_id, market, "amm_associated_seed"] - 3. Override any pool fields you need (status, fees, etc.) - - UNDERSTANDING AMM v4 POOLS: - - Raydium AMM v4 is integrated with OpenBook orderbook - - The pool address is a PDA derived from the OpenBook market address - - Each pool has token vaults for both coins (coin = base token, pc = quote token) - - TO SIMULATE PRICE CHANGES: - - Don't modify AmmInfo directly for price simulation - - Instead, use the spl-token-account-balance template to modify vault balances - - Price = pc_vault_balance / coin_vault_balance (adjusted for decimals) - - EXAMPLE - Override SOL/USDC pool to change fees: - market: select "SOL/USDC" from options - fees.swap_fee_numerator: 25 (0.25% fee = 25/10000) - fees.swap_fee_denominator: 10000 + The four out_put.swap_* counters are lifetime totals in raw mint units; no instruction prices off + them. out_put.pool_open_time (unix seconds) matters only at status 7: swaps before it fail, and the + first swap after it sets status to 6. address: - type: pda - program_id: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 - seeds: - - type: pubkey - value: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 - - type: property_ref - value: market - - type: string - value: amm_associated_seed + type: pubkey diff --git a/crates/core/src/scenarios/registry.rs b/crates/core/src/scenarios/registry.rs index edef806d0..8258ccb1d 100644 --- a/crates/core/src/scenarios/registry.rs +++ b/crates/core/src/scenarios/registry.rs @@ -20,6 +20,10 @@ pub const RAYDIUM_AMM_V4_IDL_CONTENT: &str = include_str!("./protocols/raydium/v pub const RAYDIUM_AMM_V4_OVERRIDES_CONTENT: &str = include_str!("./protocols/raydium/v4/overrides.yaml"); +pub const RAYDIUM_CPMM_IDL_CONTENT: &str = include_str!("./protocols/raydium/cpmm/v1/idl.json"); +pub const RAYDIUM_CPMM_OVERRIDES_CONTENT: &str = + include_str!("./protocols/raydium/cpmm/v1/overrides.yaml"); + pub const METEORA_DLMM_IDL_CONTENT: &str = include_str!("./protocols/meteora/dlmm/v1/idl.json"); pub const METEORA_DLMM_OVERRIDES_CONTENT: &str = include_str!("./protocols/meteora/dlmm/v1/overrides.yaml"); @@ -151,6 +155,11 @@ impl TemplateRegistry { RAYDIUM_AMM_V4_OVERRIDES_CONTENT, "raydium", ); + self.load_protocol_overrides( + RAYDIUM_CPMM_IDL_CONTENT, + RAYDIUM_CPMM_OVERRIDES_CONTENT, + "raydium", + ); } pub fn load_kamino_overrides(&mut self) { @@ -359,20 +368,56 @@ mod tests { use super::*; + #[test] + fn collection_llm_context_leads_every_template_context() { + let collection: YamlOverrideTemplateCollection = serde_yaml::from_str( + r#" +protocol: Example +version: v1 +raw_layout: true +llm_context: | + Shared lookup. +templates: + - id: with-own + name: With own + description: Has its own context + address: + type: pubkey + llm_context: | + Own guidance. + - id: without-own + name: Without own + description: Has no context of its own + address: + type: pubkey +"#, + ) + .unwrap(); + let templates = collection.to_override_templates(None).unwrap(); + assert_eq!( + templates[0].llm_context.as_deref(), + Some("Shared lookup.\n\nOwn guidance.\n") + ); + assert_eq!( + templates[1].llm_context.as_deref(), + Some("Shared lookup.\n") + ); + } + #[test] fn account_data_values_exclude_constant_ref_selectors() { let registry = TemplateRegistry::new(); let template = registry - .get("raydium-amm-custom") - .expect("template with a non-PDA constant_ref"); + .get("pyth-price-feed-v2") + .expect("template with a constant_ref"); let instance = OverrideInstance::new( template.id.clone(), 0, AccountAddress::Pubkey(Pubkey::default().to_string()), ) .with_values(HashMap::from([ - ("market".to_string(), serde_json::json!("selected-market")), - ("status".to_string(), serde_json::json!(1)), + ("feed_id".to_string(), serde_json::json!("selected-feed")), + ("posted_slot".to_string(), serde_json::json!(1)), ])); let (values, pda_refs, constant_refs) = account_data_values(&instance, Some(template)); @@ -380,7 +425,7 @@ mod tests { assert_eq!(constant_refs, 1); assert_eq!( values, - HashMap::from([("status".to_string(), serde_json::json!(1))]) + HashMap::from([("posted_slot".to_string(), serde_json::json!(1))]) ); } @@ -604,13 +649,14 @@ mod tests { fn test_registry_loads_all_protocols() { let registry = TemplateRegistry::new(); - // Pyth (1) + Jupiter (1) + Raydium CLMM (1) + Raydium AMM v4 (4) + Drift (4) + Meteora (2) + // Pyth (1) + Jupiter (1) + Raydium CLMM (3) + Raydium AMM v4 (3) + Raydium CPMM (2) + // + 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) + Phoenix Eternal (8) = 70 + // + Whirlpool (6) + SPL Token (2) + Pump (2) + PumpSwap (3) + Phoenix Eternal (8) = 73 assert_eq!( registry.count(), - 70, - "Registry should load 70 templates total" + 73, + "Registry should load 73 templates total" ); assert!(registry.contains("pyth-price-feed-v2")); @@ -618,11 +664,15 @@ mod tests { assert!(registry.contains("jupiter-token-ledger-override")); assert!(registry.contains("raydium-clmm-custom")); + assert!(registry.contains("raydium-clmm-pool-state")); + assert!(registry.contains("raydium-clmm-amm-config")); assert!(registry.contains("raydium-amm-pool-state")); assert!(registry.contains("raydium-amm-fees")); assert!(registry.contains("raydium-amm-swap-stats")); - assert!(registry.contains("raydium-amm-custom")); + + assert!(registry.contains("raydium-cpmm-pool-state")); + assert!(registry.contains("raydium-cpmm-amm-config")); assert!(registry.contains("meteora-dlmm-sol-usdc")); assert!(registry.contains("meteora-dlmm-usdt-sol")); @@ -924,8 +974,8 @@ templates: [] let raydium_templates = registry.by_protocol("Raydium"); assert_eq!( raydium_templates.len(), - 5, - "Should have 5 Raydium templates (1 CLMM + 4 AMM v4)" + 8, + "Should have 8 Raydium templates (3 CLMM + 3 AMM v4 + 2 CPMM)" ); let kamino_templates = registry.by_protocol("kamino"); @@ -1071,7 +1121,6 @@ templates: [] assert!(ids.contains(&"raydium-clmm-custom".to_string())); assert!(ids.contains(&"raydium-amm-pool-state".to_string())); - assert!(ids.contains(&"raydium-amm-custom".to_string())); assert!(ids.contains(&"jupiter-token-ledger-override".to_string())); assert!(ids.contains(&"pyth-price-feed-v2".to_string())); assert!(ids.contains(&"meteora-dlmm-sol-usdc".to_string())); @@ -1147,77 +1196,46 @@ templates: [] } #[test] - fn test_raydium_amm_v4_has_only_openbook_market_options() { - let registry = TemplateRegistry::new(); + fn bundled_idl_discriminators_are_unique_and_empty_only_for_raydium_amm_v4() { + use std::collections::{BTreeMap, HashSet}; - // Test the raydium-amm-custom template which uses openbook_market constant_ref - let raydium_v4_template = registry - .get("raydium-amm-custom") - .expect("Raydium AMM v4 custom template should exist"); - - // Print ALL constants in this template to debug - println!("Constants in raydium-amm-custom template:"); - for (name, constant) in &raydium_v4_template.constants { - println!(" - {}: {} options", name, constant.options.len()); - for (i, opt) in constant.options.iter().take(3).enumerate() { - println!(" {}: id={}, value={}", i, opt.id, opt.value); - } - } - - // Check that openbook_market constant exists - let openbook_market_constant = raydium_v4_template - .constants - .get("openbook_market") - .expect("openbook_market constant should exist"); + use anchor_lang_idl::types::Idl; - println!( - "\nopenbook_market has {} options", - openbook_market_constant.options.len() - ); - - // Print first 5 options to debug - for (i, opt) in openbook_market_constant.options.iter().take(5).enumerate() { - println!( - " Option {}: id={}, label={}, value={}", - i, opt.id, opt.label, opt.value - ); + let registry = TemplateRegistry::new(); + let mut idls: BTreeMap = BTreeMap::new(); + for idl in registry.templates.values().filter_map(|t| t.idl.as_ref()) { + idls.entry(idl.address.clone()).or_insert(idl); } - - // Should have around 100 OpenBook markets (not thousands of tokens) - assert!( - openbook_market_constant.options.len() <= 200, - "openbook_market should have only market options, not verified tokens. Got {} options", - openbook_market_constant.options.len() - ); - - // Should NOT contain token symbols like "sol" or "usdc" as IDs - // Market IDs should be like "sol-usdc" or "ray-sol" - let has_standalone_sol = openbook_market_constant - .options - .iter() - .any(|o| o.id == "sol"); assert!( - !has_standalone_sol, - "openbook_market should NOT have standalone 'sol' option (that's a token, not a market)" + idls.len() > 10, + "expected every bundled protocol IDL to be registered" ); - // Should have market pair IDs like "sol-usdc" - let has_sol_usdc_market = openbook_market_constant - .options - .iter() - .any(|o| o.id == "sol-usdc" || o.id.contains("-usdc") || o.id.contains("-sol")); - assert!( - has_sol_usdc_market, - "openbook_market should have market pair IDs like 'sol-usdc'" - ); - - // Also make sure raydium-amm-custom does NOT have token_mint constant - // (that's for CLMM v3, not AMM v4) - let has_token_mint = raydium_v4_template.constants.contains_key("token_mint"); - assert!( - !has_token_mint, - "AMM v4 template should NOT have token_mint constant (that's for CLMM v3)" - ); + for (address, idl) in idls { + let mut seen = HashSet::new(); + for account in &idl.accounts { + assert!( + account.discriminator.is_empty() || seen.insert(account.discriminator.clone()), + "IDL {address}: account {} repeats discriminator {:?}", + account.name, + account.discriminator + ); + if address == "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" { + assert!( + account.discriminator.is_empty(), + "Raydium AMM v4 account {} must declare no discriminator", + account.name + ); + } else { + assert_eq!( + account.discriminator.len(), + 8, + "IDL {address}: account {} must carry an 8-byte Anchor discriminator", + account.name + ); + } + } + } } #[test] diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 8aab2da78..7ff47e533 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -7,7 +7,9 @@ use std::{ }; use agave_feature_set::FeatureSet; -use anchor_lang_idl::types::{IdlDefinedFields, IdlGenericArg, IdlType, IdlTypeDef, IdlTypeDefTy}; +use anchor_lang_idl::types::{ + IdlAccount, IdlDefinedFields, IdlGenericArg, IdlType, IdlTypeDef, IdlTypeDefTy, +}; use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::Utc; use convert_case::Casing; @@ -77,10 +79,7 @@ use txtx_addon_kit::{ types::types::{AddonJsonConverter, Value}, }; use txtx_addon_network_svm::codec::idl::borsh_encode_value_to_idl_type; -use txtx_addon_network_svm_types::idl::{ - parse_bytes_to_value_with_expected_idl_type_def_ty, - parse_bytes_to_value_with_expected_idl_type_def_ty_with_leftover_bytes, -}; +use txtx_addon_network_svm_types::idl::parse_bytes_to_value_with_expected_idl_type_def_ty_with_leftover_bytes; use uuid::Uuid; use super::{ @@ -170,6 +169,80 @@ impl AccountUpdatePolicy { } } +/// Decodes `body` as `account_type`'s IDL-declared shape, returning the leftover bytes. +fn decode_account_body<'a>( + account_type: &IdlTypeDef, + idl_types: &Vec, + body: &'a [u8], +) -> Result<(Value, &'a [u8]), String> { + let empty_generics = vec![]; + let generics = if account_type.generics.is_empty() { + &empty_generics + } else { + &account_type.generics + }; + parse_bytes_to_value_with_expected_idl_type_def_ty_with_leftover_bytes( + body, + &account_type.ty, + idl_types, + &vec![], + generics, + ) + .map_err(|e| e.to_string()) +} + +/// The longest matching discriminator wins. A type with an empty discriminator matches only +/// when its body fills the account exactly. +fn resolve_idl_account_for_data<'a>(idl: &'a Idl, data: &[u8]) -> Result<&'a IdlAccount, String> { + let (discriminated, undiscriminated): (Vec<&IdlAccount>, Vec<&IdlAccount>) = idl + .accounts + .iter() + .partition(|account| !account.discriminator.is_empty()); + + let mut candidates: Vec<&IdlAccount> = discriminated + .into_iter() + .filter(|account| data.starts_with(&account.discriminator)) + .collect(); + if candidates.is_empty() { + candidates = undiscriminated + .into_iter() + .filter(|account| { + idl.types + .iter() + .find(|t| t.name == account.name) + .is_some_and(|account_type| { + decode_account_body(account_type, &idl.types, data) + .is_ok_and(|(_, leftover)| leftover.is_empty()) + }) + }) + .collect(); + } + + let Some(max_len) = candidates.iter().map(|a| a.discriminator.len()).max() else { + return Err( + "it matches no account type declared by the owner program's IDL, by discriminator or, for types without one, by exact size" + .to_string(), + ); + }; + let winners: Vec<&IdlAccount> = candidates + .into_iter() + .filter(|account| account.discriminator.len() == max_len) + .collect(); + + match winners.as_slice() { + [only] => Ok(only), + _ => { + let names: Vec<&str> = winners.iter().map(|a| a.name.as_str()).collect(); + Err(format!( + "its bytes match {} account types declared by the owner program's IDL ({}), \ + so the type is ambiguous", + winners.len(), + names.join(", "), + )) + } + } +} + /// Token accounts carry no Anchor discriminator, so the IDL forge path cannot decode them. fn forge_token_account_data( account: &Account, @@ -3374,14 +3447,12 @@ impl SurfnetSvm { // Get account data let account_data = account.data(); - // Check if account data is valid (has at least discriminator) - if account_data.len() < 8 { + // Check the account holds data to decode + if account_data.is_empty() { warn!( - "Account {} has insufficient data ({} bytes) for override {}. \ - Enable fetchBeforeUse: true to fetch account data from mainnet first.", - account_pubkey, - account_data.len(), - override_instance.id + "Account {} has no data for override {}. Enable fetchBeforeUse: true \ + to fetch account data from mainnet first.", + account_pubkey, override_instance.id ); continue; } @@ -3396,8 +3467,7 @@ impl SurfnetSvm { Ok(data) => data, Err(e) => { warn!( - "Failed to forge account data for {} (override {}): {}. \ - If the account doesn't exist locally, enable fetchBeforeUse: true.", + "Failed to forge account data for {} (override {}): {}", account_pubkey, override_instance.id, e ); continue; @@ -3441,13 +3511,11 @@ impl SurfnetSvm { /// Forges account data by applying overrides to existing account data /// /// This function: - /// 1. Validates account data size (must be at least 8 bytes for discriminator) - /// 2. Splits discriminator and serialized data - /// 3. Finds the account type in the IDL using the discriminator - /// 4. Deserializes the account data - /// 5. Applies field overrides using dot notation - /// 6. Re-serializes the modified data - /// 7. Reconstructs the account data with the original discriminator + /// 1. Resolves the account type, then splits its discriminator off the serialized data + /// 2. Deserializes the account data + /// 3. Applies field overrides using dot notation + /// 4. Re-serializes the modified data + /// 5. Reconstructs the account data with the original discriminator /// /// # Arguments /// * `account_pubkey` - The account address (for error messages) @@ -3464,30 +3532,13 @@ impl SurfnetSvm { idl: &Idl, overrides: &HashMap, ) -> SurfpoolResult> { - // Validate account data size - if account_data.len() < 8 { - return Err(SurfpoolError::invalid_account_data( - account_pubkey, - "Account data too small to be an Anchor account (need at least 8 bytes for discriminator)", - Some("Data length too small"), - )); - } - - // Split discriminator and data - let discriminator = &account_data[..8]; - let serialized_data = &account_data[8..]; - - // Find the account type using the discriminator - let account_def = idl - .accounts - .iter() - .find(|acc| acc.discriminator.eq(discriminator)) - .ok_or_else(|| { - SurfpoolError::internal(format!( - "Account with discriminator '{:?}' not found in IDL", - discriminator - )) - })?; + // Find the account type, then split its discriminator off the data + let account_def = resolve_idl_account_for_data(idl, account_data).map_err(|reason| { + SurfpoolError::internal(format!("Account {account_pubkey}: {reason}")) + })?; + let prefix_len = account_def.discriminator.len(); + let discriminator = &account_data[..prefix_len]; + let serialized_data = &account_data[prefix_len..]; let encoded = Self::get_forged_idl_type_data(serialized_data, idl, &account_def.name, overrides)?; @@ -3511,25 +3562,10 @@ impl SurfnetSvm { SurfpoolError::internal(format!("Type definition '{}' not found in IDL", type_name)) })?; - // Set up generics for parsing - let empty_vec = vec![]; - let idl_type_def_generics = idl - .types - .iter() - .find(|t| t.name == account_type.name) - .map(|t| &t.generics); - // Deserialize the account data using proper Borsh deserialization // Use the version that returns leftover bytes to preserve any trailing padding let (mut parsed_value, leftover_bytes) = - parse_bytes_to_value_with_expected_idl_type_def_ty_with_leftover_bytes( - serialized_data, - &account_type.ty, - &idl.types, - &vec![], - idl_type_def_generics.unwrap_or(&empty_vec), - ) - .map_err(|e| { + decode_account_body(account_type, &idl.types, serialized_data).map_err(|e| { SurfpoolError::deserialize_error( "account data", format!("Failed to deserialize account data using Borsh: {}", e), @@ -4401,33 +4437,18 @@ impl SurfnetSvm { // with the most recent one, to see if the account data can be parsed to the IDL type for idl in &ordered_available_idls { // If we have a valid IDL, use it to parse the account data - let discriminator = &data[..8]; - if let Some(matching_account) = idl - .accounts - .iter() - .find(|a| a.discriminator.eq(&discriminator)) - { + if let Ok(matching_account) = resolve_idl_account_for_data(idl, data) { // If we found a matching account, we can look up the type to parse the account if let Some(account_type) = idl.types.iter().find(|t| t.name == matching_account.name) { - let empty_vec = vec![]; - let idl_type_def_generics = idl - .types - .iter() - .find(|t| t.name == account_type.name) - .map(|t| &t.generics); - // If we found a matching account type, we can use it to parse the account data - let rest = data[8..].as_ref(); - if let Ok(parsed_value) = - parse_bytes_to_value_with_expected_idl_type_def_ty( - rest, - &account_type.ty, - &idl.types, - &vec![], - idl_type_def_generics.unwrap_or(&empty_vec), - ) + let rest = &data[matching_account.discriminator.len()..]; + // Zero-filled tail is allocated-but-unused space; any other leftover means the wrong type. + if let Some((parsed_value, _)) = + decode_account_body(account_type, &idl.types, rest) + .ok() + .filter(|(_, leftover)| leftover.iter().all(|b| *b == 0)) { return UiAccount { lamports: account.lamports(), @@ -4906,6 +4927,188 @@ mod tests { assert_eq!(&patched[72..], &account.data[72..]); } + fn undiscriminated_idl(bodies: &[(&str, &str)]) -> Idl { + let accounts: Vec = bodies + .iter() + .map(|(name, _)| format!(r#"{{"name":"{name}","discriminator":[]}}"#)) + .collect(); + let types: Vec = bodies + .iter() + .map(|(name, fields)| { + format!(r#"{{"name":"{name}","type":{{"kind":"struct","fields":[{fields}]}}}}"#) + }) + .collect(); + + serde_json::from_str(&format!( + r#"{{ + "address": "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8", + "metadata": {{"name": "undiscriminated", "version": "0.1.0", "spec": "0.1.0"}}, + "instructions": [], + "accounts": [{}], + "types": [{}] + }}"#, + accounts.join(","), + types.join(",") + )) + .expect("synthetic IDL") + } + + const TWO_U64: &str = r#"{"name":"first","type":"u64"},{"name":"second","type":"u64"}"#; + const THREE_U64: &str = r#"{"name":"alpha","type":"u64"},{"name":"beta","type":"u64"},{"name":"gamma","type":"u64"}"#; + + #[test] + fn forge_tells_undiscriminated_accounts_apart_by_exact_fill() { + let idl = undiscriminated_idl(&[("Small", TWO_U64), ("Large", THREE_U64)]); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let pubkey = Pubkey::new_unique(); + + let small: Vec = [7u64, 8].iter().flat_map(|v| v.to_le_bytes()).collect(); + let forged = svm + .get_forged_account_data( + &pubkey, + &small, + &idl, + &HashMap::from([("second".to_string(), serde_json::json!(99u64))]), + ) + .expect("16 bytes must resolve to Small"); + assert_eq!(forged.len(), small.len()); + assert_eq!(&forged[..8], &small[..8]); + assert_eq!(&forged[8..16], &99u64.to_le_bytes()); + + let large: Vec = [1u64, 2, 3].iter().flat_map(|v| v.to_le_bytes()).collect(); + let forged = svm + .get_forged_account_data( + &pubkey, + &large, + &idl, + &HashMap::from([("gamma".to_string(), serde_json::json!(55u64))]), + ) + .expect("24 bytes must resolve to Large"); + assert_eq!(forged.len(), large.len()); + assert_eq!(&forged[..16], &large[..16]); + assert_eq!(&forged[16..24], &55u64.to_le_bytes()); + + let mut trailing = small.clone(); + trailing.extend_from_slice(&[9, 9, 9]); + svm.get_forged_account_data(&pubkey, &trailing, &idl, &HashMap::new()) + .expect_err("19 bytes fill neither Small nor Large exactly"); + } + + #[test] + fn forge_refuses_undiscriminated_accounts_that_share_a_size() { + let idl = undiscriminated_idl(&[("Left", TWO_U64), ("Right", TWO_U64)]); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + + let data: Vec = [7u64, 8].iter().flat_map(|v| v.to_le_bytes()).collect(); + let error = svm + .get_forged_account_data( + &Pubkey::new_unique(), + &data, + &idl, + &HashMap::from([("second".to_string(), serde_json::json!(99u64))]), + ) + .expect_err("two 16-byte types cannot be told apart"); + assert!( + error.to_string().contains("ambiguous"), + "the error should name the ambiguity, got: {error}" + ); + } + + #[test] + fn forge_prefers_the_longer_discriminator_over_an_empty_one() { + let idl: Idl = serde_json::from_str( + r#"{ + "address": "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8", + "metadata": {"name": "mixed", "version": "0.1.0", "spec": "0.1.0"}, + "instructions": [], + "accounts": [ + {"name": "Loose", "discriminator": []}, + {"name": "Tight", "discriminator": [1, 2, 3, 4, 5, 6, 7, 8]} + ], + "types": [ + {"name": "Loose", "type": {"kind": "struct", "fields": [{"name": "a", "type": "u64"}]}}, + {"name": "Tight", "type": {"kind": "struct", "fields": [{"name": "b", "type": "u64"}]}} + ] + }"#, + ) + .expect("synthetic IDL"); + + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + let mut data: Vec = vec![1, 2, 3, 4, 5, 6, 7, 8]; + data.extend_from_slice(&9u64.to_le_bytes()); + + let forged = svm + .get_forged_account_data( + &Pubkey::new_unique(), + &data, + &idl, + &HashMap::from([("b".to_string(), serde_json::json!(4u64))]), + ) + .expect("the 8-byte discriminator must win over the empty one"); + assert_eq!( + &forged[..8], + &data[..8], + "Tight's discriminator must be preserved" + ); + assert_eq!( + &forged[8..16], + &4u64.to_le_bytes(), + "b was overridden as Tight's field, not Loose's" + ); + } + + #[test] + fn forge_round_trips_a_synthetic_amm_info() { + let registry = crate::scenarios::TemplateRegistry::new(); + let idl = registry.get("raydium-amm-fees").expect("template").idl(); + let (svm, _events_rx, _geyser_rx) = SurfnetSvm::default(); + + // status 6 (swap only) and a 25/10000 swap fee, at the offsets state.rs declares. + let mut data = vec![0u8; 752]; + data[..8].copy_from_slice(&6u64.to_le_bytes()); + data[176..184].copy_from_slice(&25u64.to_le_bytes()); + data[184..192].copy_from_slice(&10_000u64.to_le_bytes()); + + let untouched = svm + .get_forged_account_data(&Pubkey::new_unique(), &data, idl, &HashMap::new()) + .expect("a 752-byte account must resolve to AmmInfo"); + assert_eq!(untouched, data, "a no-op forge must not move a byte"); + + let forged = svm + .get_forged_account_data( + &Pubkey::new_unique(), + &data, + idl, + &HashMap::from([( + "fees.swap_fee_numerator".to_string(), + serde_json::json!(100u64), + )]), + ) + .expect("fee override on AmmInfo"); + let changed: Vec = (0..data.len()).filter(|i| forged[*i] != data[*i]).collect(); + assert_eq!( + changed, + vec![176], + "only swap_fee_numerator's low byte changes when 25 becomes 100" + ); + + data[..8].copy_from_slice(&0u64.to_le_bytes()); + let forged = svm + .get_forged_account_data( + &Pubkey::new_unique(), + &data, + idl, + &HashMap::from([("status".to_string(), serde_json::json!(1u64))]), + ) + .expect("a zero-status pool must still resolve to AmmInfo"); + let changed: Vec = (0..data.len()).filter(|i| forged[*i] != data[*i]).collect(); + assert_eq!( + changed, + vec![0], + "status must be read at offset 0, not after a discriminator" + ); + } + /// A 165-byte SPL token account of `mint` (state = Initialized), which sends `get_account` /// down the coupled-mint path. The canned server answers the mint lookup with the same body. fn canned_token_account(mint: Pubkey) -> &'static str { diff --git a/crates/core/src/tests/helpers.rs b/crates/core/src/tests/helpers.rs index 7e20538aa..17dfabdb4 100644 --- a/crates/core/src/tests/helpers.rs +++ b/crates/core/src/tests/helpers.rs @@ -12,6 +12,16 @@ use crate::{ surfnet::{PluginCommand, locker::SurfnetSvmLocker, svm::SurfnetSvm}, }; +/// Returns the byte indices whose values differ between two buffers. +pub(super) fn diff_indices(left: &[u8], right: &[u8]) -> Vec { + left.iter() + .zip(right.iter()) + .enumerate() + .filter(|(_, (left, right))| left != right) + .map(|(index, _)| index) + .collect() +} + pub fn get_free_port() -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Failed to bind to port 0: {}", e))?; diff --git a/crates/core/src/tests/kamino/mod.rs b/crates/core/src/tests/kamino/mod.rs index aef2ba25e..dccfa1def 100644 --- a/crates/core/src/tests/kamino/mod.rs +++ b/crates/core/src/tests/kamino/mod.rs @@ -22,6 +22,7 @@ use solana_pubkey::Pubkey; use crate::{ scenarios::TemplateRegistry, surfnet::{GetAccountResult, remote::SurfnetRemoteClient, svm::SurfnetSvm}, + tests::helpers::diff_indices, }; const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; @@ -68,16 +69,6 @@ async fn fetch(addresses: &[&str]) -> Vec> { .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() -} - /// A failure here means a bundled IDL disagrees with the live on-chain layout. #[tokio::test] async fn real_mainnet_accounts_round_trip_unchanged() { diff --git a/crates/core/src/tests/mod.rs b/crates/core/src/tests/mod.rs index f43607e64..602e556be 100644 --- a/crates/core/src/tests/mod.rs +++ b/crates/core/src/tests/mod.rs @@ -7,4 +7,10 @@ pub mod phoenix; pub mod plugin; #[cfg(feature = "integration-tests")] pub mod pump; +#[cfg(feature = "integration-tests")] +pub mod raydium_amm; +#[cfg(feature = "integration-tests")] +pub mod raydium_clmm; +#[cfg(feature = "integration-tests")] +pub mod raydium_cpmm; pub mod simnet_events; diff --git a/crates/core/src/tests/pump/mod.rs b/crates/core/src/tests/pump/mod.rs index 650662766..70dd91dd3 100644 --- a/crates/core/src/tests/pump/mod.rs +++ b/crates/core/src/tests/pump/mod.rs @@ -49,7 +49,7 @@ use crate::{ GetAccountResult, locker::SurfnetSvmLocker, remote::SurfnetRemoteClient, svm::SurfnetSvm, }, tests::{ - helpers::get_free_port, + helpers::{diff_indices, get_free_port}, integration::{RunloopGuard, spawn_runloop, wait_for_ready_and_connected}, }, }; @@ -125,16 +125,6 @@ async fn fetch(addresses: &[Pubkey]) -> Vec { .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() -} - /// A failure here means a bundled IDL disagrees with the live on-chain layout. #[tokio::test] async fn real_mainnet_accounts_round_trip_unchanged() { diff --git a/crates/core/src/tests/raydium_amm/mod.rs b/crates/core/src/tests/raydium_amm/mod.rs new file mode 100644 index 000000000..bbf285698 --- /dev/null +++ b/crates/core/src/tests/raydium_amm/mod.rs @@ -0,0 +1,199 @@ +//! Raydium AMM v4 integration tests. They fetch real mainnet accounts, so they need a network +//! connection and `--features integration-tests`. `SURFPOOL_TEST_RPC_URL` overrides the endpoint. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{ + GetAccountResult, locker::SurfnetSvmLocker, remote::SurfnetRemoteClient, svm::SurfnetSvm, + }, + tests::helpers::diff_indices, +}; + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +const AMM_V4_PROGRAM: Pubkey = + Pubkey::from_str_const("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"); + +const SOL_USDC: Pubkey = Pubkey::from_str_const("58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2"); +const RAY_USDC: Pubkey = Pubkey::from_str_const("6UmmUiYoBjSrhakAobJw8BvkmJtDVxaeBtbt7rxWo1mg"); +const RAY_SOL: Pubkey = Pubkey::from_str_const("AVs9TA4nWDzfPJE9gGVNJMVhcQy3V9PGazuz33BfG2RA"); + +/// `AmmInfo` is repr(C, packed) of u64/u128/Pubkey only, so Borsh lands on the same offsets. +const STATUS: usize = 0; +const SWAP_FEE_NUMERATOR: usize = 176; +const TARGET_ORDERS: usize = 592; + +const AMM_INFO_LEN: usize = 752; +const TARGET_ORDERS_LEN: usize = 2208; + +/// Fetches the accounts in one request, so every account returned is from the same slot. +async fn fetch(addresses: &[Pubkey]) -> Vec { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + client + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + .unwrap_or_else(|e| panic!("failed to fetch {addresses:?} from mainnet: {e}")) + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => { + assert_eq!( + account.owner, AMM_V4_PROGRAM, + "{address} is not owned by AMM v4 any more" + ); + account + } + GetAccountResult::None(_) => { + panic!("{address} no longer exists on mainnet; the test needs a new address") + } + }) + .collect() +} + +fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +#[tokio::test] +async fn real_mainnet_pools_round_trip_unchanged() { + let pools = [SOL_USDC, RAY_USDC, RAY_SOL]; + let accounts = fetch(&pools).await; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let idl = registry + .get("raydium-amm-pool-state") + .expect("template") + .idl(); + let pubkey = Pubkey::new_unique(); + + for (address, account) in pools.iter().zip(&accounts) { + let data = &account.data; + assert_eq!( + data.len(), + AMM_INFO_LEN, + "{address} is not an AmmInfo-sized account" + ); + + let forged = surfnet_svm + .get_forged_account_data(&pubkey, data, idl, &HashMap::new()) + .unwrap_or_else(|e| { + panic!("live pool {address} failed to decode/re-encode with the bundled IDL: {e}") + }); + assert_eq!( + forged, *data, + "live pool {address} was altered by a no-op round-trip" + ); + } +} + +#[tokio::test] +async fn a_live_target_orders_account_resolves_to_its_own_type() { + let pools = fetch(&[SOL_USDC]).await; + let target_orders = + Pubkey::try_from(&pools[0].data[TARGET_ORDERS..TARGET_ORDERS + 32]).unwrap(); + + let accounts = fetch(&[target_orders]).await; + let data = &accounts[0].data; + assert_eq!( + data.len(), + TARGET_ORDERS_LEN, + "{target_orders} is not a TargetOrders-sized account" + ); + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let idl = registry + .get("raydium-amm-pool-state") + .expect("template") + .idl(); + + let forged = surfnet_svm + .get_forged_account_data(&Pubkey::new_unique(), data, idl, &HashMap::new()) + .expect("a 2208-byte account must resolve to TargetOrders"); + assert_eq!( + forged, *data, + "the live TargetOrders was altered by a no-op round-trip" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn overrides_through_materializer_touch_only_their_own_bytes() { + let before = fetch(&[SOL_USDC]).await.remove(0); + + let new_status: u64 = if read_u64(&before.data, STATUS) == 0 { + 1 + } else { + 0 + }; + let new_fee = read_u64(&before.data, SWAP_FEE_NUMERATOR) + 1; + + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + surfnet_svm.set_account(&SOL_USDC, before.clone()).unwrap(); + let locker = SurfnetSvmLocker::new(surfnet_svm); + + let registry = TemplateRegistry::new(); + let template = registry + .get("raydium-amm-pool-state") + .expect("raydium-amm-pool-state template should exist"); + + let pool_override = OverrideInstance::new( + template.id.clone(), + 1, + AccountAddress::Pubkey(SOL_USDC.to_string()), + ) + .with_values(HashMap::from([ + ("status".to_string(), serde_json::json!(new_status)), + ( + "fees.swap_fee_numerator".to_string(), + serde_json::json!(new_fee), + ), + ])) + .with_label("Raydium AMM v4 status and fee override".to_string()); + + let mut scenario = Scenario::new( + "Raydium AMM v4 Override".to_string(), + "Change a live AmmInfo's status and swap fee through the materializer.".to_string(), + ); + scenario.add_override(pool_override); + locker.register_scenario(scenario, Some(0)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 1) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm| svm.get_account(&SOL_USDC)) + .unwrap() + .expect("account should still exist after materialization"); + + assert_eq!( + after.data.len(), + before.data.len(), + "override changed account size" + ); + let outside: Vec = diff_indices(&before.data, &after.data) + .into_iter() + .filter(|i| { + !(STATUS..STATUS + 8).contains(i) + && !(SWAP_FEE_NUMERATOR..SWAP_FEE_NUMERATOR + 8).contains(i) + }) + .collect(); + assert!( + outside.is_empty(), + "bytes outside status and swap_fee_numerator changed: {outside:?}" + ); + assert_eq!(read_u64(&after.data, STATUS), new_status); + assert_eq!(read_u64(&after.data, SWAP_FEE_NUMERATOR), new_fee); +} diff --git a/crates/core/src/tests/raydium_clmm/mod.rs b/crates/core/src/tests/raydium_clmm/mod.rs new file mode 100644 index 000000000..f04b63ded --- /dev/null +++ b/crates/core/src/tests/raydium_clmm/mod.rs @@ -0,0 +1,314 @@ +//! Raydium CLMM integration tests. They fetch real mainnet accounts, so they need a network +//! connection and `--features integration-tests`. `SURFPOOL_TEST_RPC_URL` overrides the endpoint. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{ + GetAccountResult, locker::SurfnetSvmLocker, remote::SurfnetRemoteClient, svm::SurfnetSvm, + }, + tests::helpers::diff_indices, +}; + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +const POOL_STATE_TEMPLATE: &str = "raydium-clmm-pool-state"; +const AMM_CONFIG_TEMPLATE: &str = "raydium-clmm-amm-config"; + +const SOL_USDC_POOL: Pubkey = + Pubkey::from_str_const("3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv"); + +const POOL_AMM_CONFIG_RANGE: std::ops::Range = 9..41; +const LIQUIDITY_RANGE: std::ops::Range = 237..253; +const TICK_CURRENT_RANGE: std::ops::Range = 269..273; +const STATUS_OFFSET: usize = 389; + +const TRADE_FEE_RATE_OFFSET: usize = 47; +const TRADE_FEE_RATE_RANGE: std::ops::Range = + TRADE_FEE_RATE_OFFSET..TRADE_FEE_RATE_OFFSET + 4; +const TICK_SPACING_OFFSET: usize = 51; +const AMM_CONFIG_LEN: usize = 117; + +/// Fetches the accounts in one request, so every account returned is from the same slot. +async fn fetch(addresses: &[Pubkey]) -> Vec { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + client + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + .unwrap_or_else(|e| panic!("failed to fetch {addresses:?} from mainnet: {e}")) + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account, + GetAccountResult::None(_) => { + panic!("{address} no longer exists on mainnet; the test needs a new address") + } + }) + .collect() +} + +#[tokio::test] +async fn real_mainnet_pool_round_trips_unchanged() { + let data = fetch(&[SOL_USDC_POOL]).await.remove(0).data; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + let template = registry + .get(POOL_STATE_TEMPLATE) + .expect("pool state template should exist"); + + let forged = surfnet_svm + .get_forged_account_data( + &Pubkey::new_unique(), + &data, + template.idl(), + &HashMap::new(), + ) + .unwrap_or_else(|e| panic!("live mainnet PoolState failed to decode/re-encode: {e}")); + assert_eq!( + forged, data, + "live mainnet PoolState was altered by a no-op round-trip" + ); +} + +#[tokio::test] +async fn every_amm_config_index_option_is_a_live_amm_config_account() { + let registry = TemplateRegistry::new(); + let template = registry + .get("raydium-clmm-custom") + .expect("template raydium-clmm-custom should exist"); + let amm_config_index = template + .constants + .get("amm_config_index") + .expect("amm_config_index constant should exist"); + assert!( + !amm_config_index.options.is_empty(), + "the fee tiers are the fixture here" + ); + + let account_def = template + .idl() + .accounts + .iter() + .find(|a| a.name == "AmmConfig") + .expect("AmmConfig not in the IDL"); + + let addresses: Vec = amm_config_index + .options + .iter() + .map(|option| { + let address = option + .metadata + .get("derived_address") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("option {} documents no derived_address", option.id)); + Pubkey::from_str_const(address) + }) + .collect(); + let accounts = fetch(&addresses).await; + + for (option, account) in amm_config_index.options.iter().zip(&accounts) { + let data = &account.data; + assert_eq!( + data.len(), + AMM_CONFIG_LEN, + "AmmConfig for option {} has the wrong size live", + option.id + ); + assert_eq!( + &data[..8], + account_def.discriminator.as_slice(), + "AmmConfig for option {} has the wrong discriminator live", + option.id + ); + + let tick_spacing = u16::from_le_bytes( + data[TICK_SPACING_OFFSET..TICK_SPACING_OFFSET + 2] + .try_into() + .unwrap(), + ); + let trade_fee_rate = u32::from_le_bytes( + data[TRADE_FEE_RATE_OFFSET..TRADE_FEE_RATE_OFFSET + 4] + .try_into() + .unwrap(), + ); + + let expect_u64 = |field: &str| -> u64 { + option + .metadata + .get(field) + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| panic!("option {} documents no {field} metadata", option.id)) + }; + + assert_eq!( + tick_spacing as u64, + expect_u64("tick_spacing"), + "option {} tick_spacing metadata disagrees with the live AmmConfig", + option.id + ); + assert_eq!( + trade_fee_rate as u64, + expect_u64("fee_rate_bps") * 100, + "option {} live trade_fee_rate should be fee_rate_bps * 100", + option.id + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn overrides_through_materializer_touch_only_their_own_bytes() { + let before = fetch(&[SOL_USDC_POOL]).await.remove(0); + + let new_status: u8 = if before.data[STATUS_OFFSET] == 0 { + 31 + } else { + 0 + }; + let new_liquidity = u128::from_le_bytes(before.data[LIQUIDITY_RANGE].try_into().unwrap()) ^ 1; + let new_tick = i32::from_le_bytes(before.data[TICK_CURRENT_RANGE].try_into().unwrap()) - 1000; + + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + surfnet_svm + .set_account(&SOL_USDC_POOL, before.clone()) + .unwrap(); + let locker = SurfnetSvmLocker::new(surfnet_svm); + + let registry = TemplateRegistry::new(); + let template = registry + .get(POOL_STATE_TEMPLATE) + .expect("pool state template should exist"); + + let pool_override = OverrideInstance::new( + template.id.clone(), + 1, + AccountAddress::Pubkey(SOL_USDC_POOL.to_string()), + ) + .with_values(HashMap::from([ + ("status".to_string(), serde_json::json!(new_status)), + ( + "liquidity".to_string(), + serde_json::json!(new_liquidity.to_string()), + ), + ("tick_current".to_string(), serde_json::json!(new_tick)), + ])) + .with_label("Raydium CLMM pool override".to_string()); + + let mut scenario = Scenario::new( + "Raydium CLMM Pool Override".to_string(), + "Change a live PoolState's status, liquidity and tick through the materializer." + .to_string(), + ); + scenario.add_override(pool_override); + locker.register_scenario(scenario, Some(0)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 1) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm| svm.get_account(&SOL_USDC_POOL)) + .unwrap() + .expect("account should still exist after materialization"); + + assert_eq!( + after.data.len(), + before.data.len(), + "override changed account size" + ); + let outside: Vec = diff_indices(&before.data, &after.data) + .into_iter() + .filter(|i| { + *i != STATUS_OFFSET && !LIQUIDITY_RANGE.contains(i) && !TICK_CURRENT_RANGE.contains(i) + }) + .collect(); + assert!( + outside.is_empty(), + "bytes outside the overridden fields changed: {outside:?}" + ); + assert_eq!(after.data[STATUS_OFFSET], new_status); + assert_eq!( + u128::from_le_bytes(after.data[LIQUIDITY_RANGE].try_into().unwrap()), + new_liquidity + ); + assert_eq!( + i32::from_le_bytes(after.data[TICK_CURRENT_RANGE].try_into().unwrap()), + new_tick + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fee_tier_override_on_the_pools_amm_config_touches_only_the_trade_fee() { + let pool = fetch(&[SOL_USDC_POOL]).await.remove(0); + let amm_config = Pubkey::try_from(&pool.data[POOL_AMM_CONFIG_RANGE]).unwrap(); + let before = fetch(&[amm_config]).await.remove(0); + assert_eq!( + before.data.len(), + AMM_CONFIG_LEN, + "{amm_config} is not an AmmConfig" + ); + + let new_trade_fee_rate = + u32::from_le_bytes(before.data[TRADE_FEE_RATE_RANGE].try_into().unwrap()) + 1; + + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + surfnet_svm + .set_account(&amm_config, before.clone()) + .unwrap(); + let locker = SurfnetSvmLocker::new(surfnet_svm); + + let registry = TemplateRegistry::new(); + let template = registry + .get(AMM_CONFIG_TEMPLATE) + .expect("amm config template should exist"); + + let fee_override = OverrideInstance::new( + template.id.clone(), + 1, + AccountAddress::Pubkey(amm_config.to_string()), + ) + .with_values(HashMap::from([( + "trade_fee_rate".to_string(), + serde_json::json!(new_trade_fee_rate), + )])) + .with_label("Raydium CLMM fee tier override".to_string()); + + let mut scenario = Scenario::new( + "Raydium CLMM Fee Tier Override".to_string(), + "Change the trade fee of the AmmConfig a live pool points to.".to_string(), + ); + scenario.add_override(fee_override); + locker.register_scenario(scenario, Some(0)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 1) + .await + .unwrap(); + + let after = locker + .with_svm_reader(|svm| svm.get_account(&amm_config)) + .unwrap() + .expect("account should still exist after materialization"); + + let outside: Vec = diff_indices(&before.data, &after.data) + .into_iter() + .filter(|i| !TRADE_FEE_RATE_RANGE.contains(i)) + .collect(); + assert!( + outside.is_empty(), + "bytes outside trade_fee_rate changed: {outside:?}" + ); + assert_eq!( + u32::from_le_bytes(after.data[TRADE_FEE_RATE_RANGE].try_into().unwrap()), + new_trade_fee_rate + ); +} diff --git a/crates/core/src/tests/raydium_cpmm/mod.rs b/crates/core/src/tests/raydium_cpmm/mod.rs new file mode 100644 index 000000000..ad316eab5 --- /dev/null +++ b/crates/core/src/tests/raydium_cpmm/mod.rs @@ -0,0 +1,213 @@ +//! Raydium CPMM integration tests. They fetch real mainnet accounts, so they need a network +//! connection and `--features integration-tests`. `SURFPOOL_TEST_RPC_URL` overrides the endpoint. + +use std::collections::HashMap; + +use solana_account::Account; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use surfpool_types::{AccountAddress, OverrideInstance, Scenario}; + +use crate::{ + scenarios::TemplateRegistry, + surfnet::{ + GetAccountResult, locker::SurfnetSvmLocker, remote::SurfnetRemoteClient, svm::SurfnetSvm, + }, + tests::helpers::diff_indices, +}; + +const RPC_URL_ENV: &str = "SURFPOOL_TEST_RPC_URL"; +const DEFAULT_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; + +const POOL_STATE_TEMPLATE: &str = "raydium-cpmm-pool-state"; +const AMM_CONFIG_TEMPLATE: &str = "raydium-cpmm-amm-config"; + +const PROGRAM_ID: Pubkey = Pubkey::from_str_const("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C"); +const WSOL_USELESS_POOL: Pubkey = + Pubkey::from_str_const("Q2sPHPdUWFMg7M7wwrQKLrn619cAucfRsmhVJffodSp"); +const SOL_USDC_POOL: Pubkey = + Pubkey::from_str_const("47hq28mcL7q5GhBg7epyGF2dnuJd4MKFt8QhT7CzYUp4"); +const AMM_CONFIG_INDEX_0: Pubkey = + Pubkey::from_str_const("D4FPEruKEHrG5TenZ2mpDGEfu1iUvTiqBxvpU8HLBvC2"); + +const POOL_STATE_LEN: usize = 637; +const AMM_CONFIG_LEN: usize = 236; +const AMM_CONFIG_RANGE: std::ops::Range = 8..40; +const VAULT_0_RANGE: std::ops::Range = 72..104; +const VAULT_1_RANGE: std::ops::Range = 104..136; +const MINT_0_RANGE: std::ops::Range = 168..200; +const MINT_1_RANGE: std::ops::Range = 200..232; +const STATUS_OFFSET: usize = 329; +const OPEN_TIME_RANGE: std::ops::Range = 373..381; +const TRADE_FEE_RATE_RANGE: std::ops::Range = 12..20; + +/// Fetches the accounts in one request, so every account returned is from the same slot. +async fn fetch(addresses: &[Pubkey]) -> Vec { + let client = SurfnetRemoteClient::new( + std::env::var(RPC_URL_ENV).unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()), + ); + client + .get_multiple_accounts(addresses, CommitmentConfig::confirmed()) + .await + .unwrap_or_else(|e| panic!("failed to fetch {addresses:?} from mainnet: {e}")) + .into_iter() + .zip(addresses) + .map(|(result, address)| match result { + GetAccountResult::FoundAccount(_, account, _) + | GetAccountResult::FoundCoupledAccount((_, account), _, _) => account, + GetAccountResult::None(_) => { + panic!("{address} no longer exists on mainnet; the test needs a new address") + } + }) + .collect() +} + +fn pubkey_at(data: &[u8], range: std::ops::Range) -> Pubkey { + Pubkey::try_from(&data[range]).unwrap() +} + +#[tokio::test] +async fn real_mainnet_accounts_round_trip_unchanged() { + let accounts = fetch(&[WSOL_USELESS_POOL, SOL_USDC_POOL, AMM_CONFIG_INDEX_0]).await; + + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + let registry = TemplateRegistry::new(); + for (account, template_id, len) in [ + (&accounts[0], POOL_STATE_TEMPLATE, POOL_STATE_LEN), + (&accounts[1], POOL_STATE_TEMPLATE, POOL_STATE_LEN), + (&accounts[2], AMM_CONFIG_TEMPLATE, AMM_CONFIG_LEN), + ] { + assert_eq!(account.owner, PROGRAM_ID, "{template_id} fixture owner"); + assert_eq!(account.data.len(), len, "{template_id} fixture size"); + let template = registry.get(template_id).expect("template should exist"); + let forged = surfnet_svm + .get_forged_account_data( + &Pubkey::new_unique(), + &account.data, + template.idl(), + &HashMap::new(), + ) + .unwrap_or_else(|e| panic!("live {template_id} account failed to round-trip: {e}")); + assert_eq!( + forged, account.data, + "live {template_id} account was altered by a no-op round-trip" + ); + } +} + +/// The pair lookup in the templates' `llm_context` reads the mints at 168 and 200, the vaults at +/// 72 and 104 and the fee tier at 8; each vault is the program's PDA over its pool and mint. +#[tokio::test] +async fn pool_lookup_offsets_hold_the_mints_vaults_and_fee_tier() { + let pool = fetch(&[WSOL_USELESS_POOL]).await.remove(0).data; + + assert_eq!(pubkey_at(&pool, AMM_CONFIG_RANGE), AMM_CONFIG_INDEX_0); + for (vault, mint) in [(VAULT_0_RANGE, MINT_0_RANGE), (VAULT_1_RANGE, MINT_1_RANGE)] { + let (expected, _) = Pubkey::find_program_address( + &[ + b"pool_vault", + WSOL_USELESS_POOL.as_ref(), + pubkey_at(&pool, mint).as_ref(), + ], + &PROGRAM_ID, + ); + assert_eq!(pubkey_at(&pool, vault), expected); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn overrides_through_materializer_touch_only_their_own_bytes() { + let mut fetched = fetch(&[WSOL_USELESS_POOL, AMM_CONFIG_INDEX_0]).await; + let config_before = fetched.pop().unwrap(); + let pool_before = fetched.pop().unwrap(); + + let new_status: u8 = if pool_before.data[STATUS_OFFSET] == 0 { + 4 + } else { + 0 + }; + let new_open_time = + u64::from_le_bytes(pool_before.data[OPEN_TIME_RANGE].try_into().unwrap()) + 3600; + let new_trade_fee_rate = + u64::from_le_bytes(config_before.data[TRADE_FEE_RATE_RANGE].try_into().unwrap()) + 7500; + + let (mut surfnet_svm, _simnet_events_rx, _geyser_events_rx) = SurfnetSvm::default(); + surfnet_svm + .set_account(&WSOL_USELESS_POOL, pool_before.clone()) + .unwrap(); + surfnet_svm + .set_account(&AMM_CONFIG_INDEX_0, config_before.clone()) + .unwrap(); + let locker = SurfnetSvmLocker::new(surfnet_svm); + + let mut scenario = Scenario::new( + "Raydium CPMM Overrides".to_string(), + "Halt a live pool, move its open time and raise its fee tier through the materializer." + .to_string(), + ); + scenario.add_override( + OverrideInstance::new( + POOL_STATE_TEMPLATE.to_string(), + 1, + AccountAddress::Pubkey(WSOL_USELESS_POOL.to_string()), + ) + .with_values(HashMap::from([ + ("status".to_string(), serde_json::json!(new_status)), + ("open_time".to_string(), serde_json::json!(new_open_time)), + ])) + .with_label("Raydium CPMM pool override".to_string()), + ); + scenario.add_override( + OverrideInstance::new( + AMM_CONFIG_TEMPLATE.to_string(), + 1, + AccountAddress::Pubkey(AMM_CONFIG_INDEX_0.to_string()), + ) + .with_values(HashMap::from([( + "trade_fee_rate".to_string(), + serde_json::json!(new_trade_fee_rate), + )])) + .with_label("Raydium CPMM fee tier override".to_string()), + ); + locker.register_scenario(scenario, Some(0)).unwrap(); + locker + .materialize_overrides_for_slot(&None, 1) + .await + .unwrap(); + + let read = |address: Pubkey| { + locker + .with_svm_reader(|svm| svm.get_account(&address)) + .unwrap() + .expect("account should still exist after materialization") + }; + let pool_after = read(WSOL_USELESS_POOL); + let config_after = read(AMM_CONFIG_INDEX_0); + + let pool_outside: Vec = diff_indices(&pool_before.data, &pool_after.data) + .into_iter() + .filter(|i| *i != STATUS_OFFSET && !OPEN_TIME_RANGE.contains(i)) + .collect(); + assert!( + pool_outside.is_empty(), + "pool bytes outside status and open_time changed: {pool_outside:?}" + ); + assert_eq!(pool_after.data[STATUS_OFFSET], new_status); + assert_eq!( + u64::from_le_bytes(pool_after.data[OPEN_TIME_RANGE].try_into().unwrap()), + new_open_time + ); + + let config_outside: Vec = diff_indices(&config_before.data, &config_after.data) + .into_iter() + .filter(|i| !TRADE_FEE_RATE_RANGE.contains(i)) + .collect(); + assert!( + config_outside.is_empty(), + "AmmConfig bytes outside trade_fee_rate changed: {config_outside:?}" + ); + assert_eq!( + u64::from_le_bytes(config_after.data[TRADE_FEE_RATE_RANGE].try_into().unwrap()), + new_trade_fee_rate + ); +} diff --git a/crates/mcp/src/surfpool/mod.rs b/crates/mcp/src/surfpool/mod.rs index 9e23884ce..8653ca44b 100644 --- a/crates/mcp/src/surfpool/mod.rs +++ b/crates/mcp/src/surfpool/mod.rs @@ -198,7 +198,9 @@ pub struct CallSurfnetRpcParams { A list of all the RPC methods available can be found at str:///rpc_endpoints" )] pub method: String, - #[schemars(description = "The parameters to pass to the RPC method")] + #[schemars( + description = "The parameters to pass to the RPC method. Wrap every value at every depth by its type: a string as {\"String\":\"...\"}, a whole number as {\"Number\":5}, a boolean as {\"Bool\":true}, a list as {\"Array\":[...]} and an object as {\"Object\":{\"key\":}}; object keys stay plain. Example for getProgramAccounts with a filter: [{\"String\":\"\"},{\"Object\":{\"encoding\":{\"String\":\"base64\"},\"filters\":{\"Array\":[{\"Object\":{\"memcmp\":{\"Object\":{\"offset\":{\"Number\":0},\"bytes\":{\"String\":\"\"}}}}}]}}}]" + )] pub params: Vec, } diff --git a/crates/types/src/scenarios.rs b/crates/types/src/scenarios.rs index 00e4ecf76..bdd148228 100644 --- a/crates/types/src/scenarios.rs +++ b/crates/types/src/scenarios.rs @@ -1031,6 +1031,9 @@ pub struct YamlOverrideTemplateCollection { /// Protocol-specific constants shared by all templates in this collection #[serde(default)] pub constants: HashMap, + /// LLM context shared by all templates in this collection, placed before each template's own + #[serde(default)] + pub llm_context: Option, /// Selects offset-and-encoding writes for programs with no usable IDL. #[serde(default)] pub raw_layout: bool, @@ -1483,6 +1486,10 @@ impl YamlOverrideTemplateCollection { let account_type = entry .idl_account_name .unwrap_or_else(|| default_account_type.clone()); + let llm_context = match (&self.llm_context, entry.llm_context) { + (Some(shared), Some(own)) => Some(format!("{}\n\n{own}", shared.trim_end())), + (shared, own) => own.or_else(|| shared.clone()), + }; let properties = describe_properties_from_idl(entry.properties, idl.as_ref(), &account_type) .map_err(|e| format!("template '{}': {e}", entry.id))?; @@ -1497,7 +1504,7 @@ impl YamlOverrideTemplateCollection { account_type, constants: constants.clone(), tags: self.tags.clone(), - llm_context: entry.llm_context, + llm_context, raw_layout: self.raw_layout, }) })