Skip to content

Look up the buffers of the most traded mints at compile time - #175

Merged
kaze-cow merged 23 commits into
mainfrom
known-buffer-pdas
Oct 2, 2026
Merged

kaze-cow merged 23 commits into
mainfrom
known-buffer-pdas

Conversation

@kaze-cow

@kaze-cow kaze-cow commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

BeginSettle re-derives the buffer PDA of every push's buy mint with create_program_address, which costs 1500 CU per order. Most volume is in a few tokens, so this derives their buffers at compile time instead, the same way STATE_PDA is.

What changes

validate_buffer_pda first looks the buy mint up in a compile-time table of the buffer PDAs for the 63 most traded verified tokens. It falls back to the current derivation when the mint isn't in the table.

  • A file, interface/src/pda/buffer/known_mints.rs holds a list of mints that should be pre-indexed for the table. (see the script below for an example of how this file was originally generated. the exact mints we choose to put in this file is out of scope).
  • The buffer addresses are derived from that list with const_crypto, so a version bump carries through without regenerating. Deriving this many PDAs trips long_running_const_eval, so the lint is allowed on that one constant.
  • Lookup (perfect hash): a const block tries multipliers from a fixed pseudo-random sequence until one sends every known mint to its own slot of a 512-entry index table. A lookup is one multiply, shift and load, then a full comparison of the mint. An empty slot holds u8::MAX, which points past the end of the buffer list, so a miss needs no extra check. If no multiplier works, the build fails; that's certain if two mints share their leading 8 bytes.
  • No program ID check: the program only works at crate::ID (Initialize fails elsewhere because of STATE_PDA), so known mints are checked against crate::ID's buffers whatever program_id is. The fallback derivation still uses program_id.
  • No stored bumps: the seeds are never needed.

63 mints were chosen based on the table size analysis below and, by arbitrary design, its 2**n - 1 number, which can be useful for many data structures in computing. One additional account will be added relating to #174 separately, bringingthe total to 64. The number is honsetly somewhat arbitrary now that we are not using a binary search to lookup the desired value, but it feels nice as a programmer 😅

On using a hashing library

It would be really nice if we didn't have to have the code for find_slot_multiplier, but there don't seem to be any great libraries

Results

main this PR
push with a known buy mint (pushes_a_single_order_of_a_known_mint) 6067 CU 4576 CU (−1491)
push with any other buy mint (pushes_a_single_order) 6067 CU 6082 CU (+15)
cow_settlement.so 62,280 B 67,168 B (+4,632 B, ≈ 0.032 SOL of rent)

The fast path pays for itself once about 1% of pushes are for known mints.

Commits:

  1. 7a226f6 adds the table with a binary search (4636 / 6143 CU).
  2. 9eb5cd9 drops the program ID check (4618 / 6125 CU).
  3. 47648ac switches to the perfect hash (4576 / 6082 CU).

Picking the table size

This sweep was run on the first commit's binary search, which still had the program ID check and stored bumps. The size, rent and build-time columns still describe how the table scales. The CU columns are higher than with the perfect hash, whose cost doesn't grow with the table.

All sizes use the same Jupiter snapshot, and "vs main" is against main's validate_buffer_pda. Volume share is each size's share of 24h volume across Jupiter's verified tokens. Build time is how long it takes to run just build to the nearest second.

mints .so bytes vs main extra rent (SOL) program build (s) unknown-mint push CU vs main known-mint push CU vs main 24h volume share
5 +1,152 0.008 2 +52 −1453 85.4%
10 +1,600 0.011 2 +58 −1447 90.3%
50 +4,672 0.033 3 +75 −1430 97.7%
100 +8,368 0.058 4 +84 −1419 99.1%
200 +15,664 0.109 7 +93 −1413 99.7%
500 +37,360 0.260 17 +103 −1402 99.97%
1000 +73,456 0.511 37 +111 −1394 100%
3760 (all) +272,368 1.896 229 +128 −1379 100%

63 mints covered 98.3% of volume in that snapshot on Jupiter.

Out of scope

Jupiter may not be the best source of generating this list of tokens. We can revamp the list, or even count, prior to launch upon further discussion with the team.

The following example script was used to generate the initial list of 63 accounts as a starting point:

import { writeFileSync } from "node:fs";

const COUNT = 63;
const SOURCE = "https://lite-api.jup.ag/tokens/v2/tag?query=verified";
const OUTPUT = new URL(
  "./interface/src/pda/buffer/known_mints.rs",
  import.meta.url,
);

const response = await fetch(SOURCE);
if (!response.ok) {
  throw new Error(`${SOURCE} answered ${response.status}`);
}
const tokens = await response.json();

const volume = ({ stats24h }) =>
  (stats24h?.buyVolume ?? 0) + (stats24h?.sellVolume ?? 0);
const ranked = tokens.sort((a, b) => volume(b) - volume(a)).slice(0, COUNT);
if (ranked.length !== COUNT) {
  throw new Error(
    `expected at least ${COUNT} verified tokens, got ${ranked.length}`,
  );
}

// Volume picks which mints make the cut, but emit them ordered by address so
// day-to-day volume shuffles don't churn the generated file.
ranked.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));

// Symbols are free-form text; keep only what reads safely in a line comment.
const label = (symbol) => symbol.replace(/[^\x20-\x7e]/g, "").trim() || "?";
const date = new Date().toISOString().slice(0, 10);
// Aligns the symbol comments the way rustfmt does.
const width = Math.max(...ranked.map(({ id }) => id.length));

writeFileSync(
  OUTPUT,
  `/// The ${COUNT} verified tokens with the highest 24h trading volume on Jupiter,
/// sorted by mint address.
pub(super) const KNOWN_MINTS: [&str; ${COUNT}] = [
${ranked.map(({ id, symbol }) => `    ${`"${id}",`.padEnd(width + 3)} // ${label(symbol)}`).join("\n")}
];
`,
);

Test plan

  • Ensure test coverage is sufficient

🤖 Generated with Claude Code

kaze-cow and others added 5 commits September 30, 2026 22:45
`validate_buffer_pda` now checks a compile-time table of the buffer PDAs
for the 63 most traded verified tokens before falling back to
`create_program_address`, saving ~1430 CU per push of a known buy mint
at the cost of ~76 CU per push of any other.

`just generate-known-mints` refreshes the mint list from Jupiter's
verified token list, ranked by 24h volume. The buffer addresses are
derived from it with const-crypto, like `STATE_PDA`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The settlement program only works at `crate::ID` (`Initialize` fails
elsewhere because of `STATE_PDA`), so checking `program_id` before the
known buffer lookup costs 18 CU per push for nothing.

Also check that every known mint looks up its own entry and canonical
address, not just some entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The known mints are fixed at compile time, so a const block searches for
a multiplier that sends each one to its own slot of a 256-entry index
table. A lookup is now one multiply, shift and load plus the full mint
comparison, instead of a 7-comparison binary search: 42 CU cheaper for
known and unknown mints alike, and 744 bytes smaller.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hand-rolled splitmix64 was hard to review without knowing it. The
candidates are now the leading 8 bytes of SHA-256(0), SHA-256(1), ...,
through the const-crypto dependency we already have.

SHA-256 is much slower to evaluate at compile time, so the slot table
grows to 512 entries: today's mints then need 41 candidates instead of
1,364, keeping the build free of long-running const eval warnings at a
cost of 256 bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kaze-cow
kaze-cow marked this pull request as ready for review September 30, 2026 14:33
@kaze-cow
kaze-cow requested a review from a team as a code owner September 30, 2026 14:33

@fedgiac fedgiac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extremely interesting hack, I really like the idea and how it turned out. I really want to merge this! const is fun. 😄

The only design change I'm considering right now (but I'm not sure if it's actually better than the current design) is generating the buffers and the multiplier through the same script that generates the mints.
This moves compilation times (basically) back to what they were before. However, the drawback is that the generation script needs to be rerun on every minor or major version bump.

Compilation time still seems under control, so maybe it's better to keep it as it is.

Comment thread Justfile Outdated
Comment thread interface/scripts/generate-known-mints.mjs Outdated
Comment thread interface/scripts/generate-known-mints.mjs Outdated
Comment thread interface/src/pda/buffer/known_mints.rs Outdated
Comment thread interface/src/pda/buffer.rs
Comment thread interface/src/pda/buffer.rs
Comment thread interface/src/pda/buffer.rs
Comment thread interface/src/pda/buffer.rs
Comment thread programs/settlement/tests/common/token.rs Outdated
Comment thread programs/settlement/tests/finalize_settle_pushes.rs Outdated
@kaze-cow

kaze-cow commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

The only design change I'm considering right now (but I'm not sure if it's actually better than the current design) is generating the buffers and the multiplier through the same script that generates the mints.

That was my original thought too, but then seeing what the AI was able to accomplish with just const generation, it seemed best for clarity to keep the generation script simple. The compilation times are low enough (and we are close enough to the end of the project) that it doesn't seem. I don't know why, but I just want to avoid having to make a lot of bespoke generation code

If we still have concerns, I actually would rather try moving all of this logic into an isolated cargo crate, which would mean the crate is only need to be rebuilt when the generation changes.

@kaze-cow
kaze-cow requested a review from fedgiac October 2, 2026 08:53

@fedgiac fedgiac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonderful!

@kaze-cow
kaze-cow merged commit 266016e into main Oct 2, 2026
19 checks passed
@kaze-cow
kaze-cow deleted the known-buffer-pdas branch October 2, 2026 16:38
kaze-cow added a commit that referenced this pull request Oct 6, 2026
# Description

Moves the effective buffer account from the state PDA to what the buffer
account *would* be if the system program was a real mint.

## Motivation

Using the state PDA as the source buffer means that the state PDA also
needs to be declared as a writable account (rather than readable, as it
currently is). This effectively prevents two otherwise unrelated
settlement transactions from being included in parallel on a single
solana block.

## Considerations

### The token source buffer

Before this needed to be set by the solver to the state PDA. Originally
I really liked the idea of having the buffer match with the usual
buffer_pda pattern, but with recent suggestions from @fedgiac , this may
be more of a footgun. So now it uses a completely indepedently derived
seed.

### Creating the buffer account

Unlike other buffer accounts, what we need is an empty PDA at the
derived address.

One option is to do it as part of `CreateBuffers`. This would basically
come down to effectively returning early after running the
`CanonicalPda` call to create the necessary account, and modifying the
account length.

It could also be done in the `Initialize`, but this is a breaking change
since the new account needs to be supplied and existing settlement
programs cannot call Initialize twice, but it is the most natural option
considering the program lifecycle. This is the option used in this PR.

### Piggybacking off of #175 

#175 made it possible to look up from a small list of "known" mints the
correct buffer pda. We can use this exact same lookup for the native
buffer as well, since it uses the same code path. This means we no
longer need `is_native_sol` for a comparison anywhere in the codebase,
and we use the same perfect hash to lookup the correct native buffer,
simplifying the code paths in some ways.

## Validating the Native SOL buffer

Since the native SOL buffer now derives the same way as any other
buffer, we can remove the if statement that does the validation of the
buffer. I decided not to do this

## How to test

Read the above methodology and check the changes. They are mostly
refactoring/renaming from `STATE_PDA` to `NATIVE_SOL_BUFFER_PDA`.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants