Skip to content

Support Token-2022 in BeginSettle and FinalizeSettle - #128

Open
kaze-cow wants to merge 4 commits into
mainfrom
kaze/sc-153-token-2022-settle
Open

Support Token-2022 in BeginSettle and FinalizeSettle#128
kaze-cow wants to merge 4 commits into
mainfrom
kaze/sc-153-token-2022-settle

Conversation

@kaze-cow

@kaze-cow kaze-cow commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Add support for Token2022. Both token programs can be used in the same settlement without issues.

Motivation

BeginSettle and FinalizeSettle each took a token_program account and rejected anything but the legacy SPL Token program. As settlements may need to pull or push from many different in the same settlement, we need a way to specify both.

Methodology

Rather than including one token program per order, since there are only two possible token programs we are ever anticipating needing to support, we add the token2022 program to the list of fixed accounts for both BeginSettle and FinalizeSettle.

If either the token2022 program or the SPL token program is not required to complete the relevant settlement instruction, they can be excluded without incurring account cost by setting the slot to the system program.

Compute cost

bench-report.json is regenerated. The settle benchmarks each gain one account, 34 transaction bytes, and 50–170 CU on top of the Token-2022 dispatch above.

Out of scope

Integration tests are expanded in #121

Test Plan

Verify methodology.

🤖 Generated with Claude Code

`CreateBuffer` and `ReclaimBuffer` each took a `token_program` account and
rejected anything that wasn't the legacy SPL Token program. They now accept
Token-2022 as well and issue all of their CPIs against whichever of the two
they were handed, so a buffer can be allocated, initialized and closed under
either.

Token-2022 encodes the instructions this program issues exactly as the legacy
program does, so only the CPI target changes. Two things do differ:

- Account data. A Token-2022 account carrying extensions is longer than the
  base layout, so the legacy reader (exact length, legacy owner) rejects it.
  `token::read_token_account` dispatches on the validated program and reads by
  value, which also drops the borrow before `ReclaimBuffer` closes the same
  account.

- Buffer sizing. A buffer now gets the length its mint actually needs: a mint
  with no extension data keeps the base layout, and anything longer is priced
  by asking the token program via `GetAccountDataSize`, the way the
  associated-token-account program does. That keeps the answer authoritative at
  run time rather than freezing a mint-extension-to-account-extension table
  into the program.

The program account is shared by the whole instruction, so the mints one
instruction touches must all live under the same token program; splitting a
mixed batch across two instructions is the caller's job.

Adds `SettlementError::BufferSizeUnavailable` (35), reachable only defensively:
a token program that fails the size query aborts the instruction on its own.

`BeginSettle` and `FinalizeSettle` keep rejecting everything but the legacy
program; Token-2022 for the settlement pair follows separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kaze-cow
kaze-cow requested a review from a team as a code owner August 28, 2026 07:23
@linear-code

linear-code Bot commented Aug 28, 2026

Copy link
Copy Markdown

SC-153

kaze-cow and others added 3 commits August 28, 2026 17:23
`spl-token-interface` was a dependency of the interface crate for one
thing: a 32-byte constant naming a program this code never calls
directly. `spl-token-2022-interface` already carries that address in
`inline_spl_token`, which exists precisely so a program that has to
recognize both doesn't grow a second dependency for the one it only
compares against. Both ids now come from there, and the legacy crate
leaves the interface crate's dependency graph -- and with it the
settlement program's.

`instruction::settle` re-exported the same id straight from the legacy
crate, so it moves to `token_program` too. That was the second import
keeping the dependency alive, not a cosmetic change.

The `.so` is byte-identical at 51,056 bytes: the constant was already
inlined, so this narrows the dependency graph rather than the program.

The program crate keeps `pinocchio-token`. Its state readers are not
parameterized over the token program the way 0.7's instruction builders
are -- `pinocchio_token_2022::state::Account` hardcodes an owner check
against Token-2022 in all of its safe constructors -- so reading a legacy
account without it means `from_bytes_unchecked` plus hand-rolled owner
and length checks, in exchange for a crate `pinocchio-token-2022` depends
on anyway.

`test-cli` keeps the legacy crate as well. It talks to no other program,
and the two crates' `native_mint` are different addresses, so swapping
them there is a change to make deliberately rather than in passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`BeginSettle` and `FinalizeSettle` each took a `token_program` account and
rejected anything that wasn't the legacy SPL Token program, so the buffers
`CreateBuffer` can now open under Token-2022 had no way to be settled. They
accept it too, and issue every transfer against whichever of the two they were
handed, through the same `token::validate_token_program` gate the buffer
instructions use.

Token-2022 encodes `Transfer` exactly as the legacy program does, so only the
CPI target changes. What differs is the account data: a Token-2022 account
carrying extensions is longer than the base layout, and the legacy reader
insists on an exact length. Both sides now read through
`token::read_token_account`, which dispatches on the validated program — the
sell account's owner in `BeginSettle`, the destination's mint in
`FinalizeSettle`.

That reader grows the `mint` and `owner` fields the settlement needs and the
buffer instructions didn't, which costs `ReclaimBuffer` a little: its
`max_buffers_in_one_instruction` goes 137,046 -> 138,392 CU for the wider read.
The settle benchmarks rise 0.3-1.6% from the added dispatch.

The `token_program` account is shared by the whole instruction, so every token
one settlement touches must live under the same program; a mixed settlement
still needs two instruction pairs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`BeginSettle` and `FinalizeSettle` took one `token_program` account, so
every token an instruction touched had to live under the same program:
settling a legacy SPL mint and a Token-2022 mint meant two instruction
pairs. They now take one account per supported program, at fixed
positions after the state PDA, and issue each transfer against the
program that owns the account it moves. One pair can settle both, and the
two sides of a single order need not agree — the pull follows the sell
account's owner, the push the buy account's.

A program the settlement doesn't touch is left out by putting the system
program in its slot. The transfers still need their program named by the
transaction, so the placeholder is how an instruction says this one isn't;
in a transaction that already references the system program it costs an
account index instead of another 32-byte address.

Nothing new goes into the instruction data: the owner is the authority on
which program an account belongs to, and the slots only decide whether
the settlement can reach it. Resolving an account gives one of three
answers:

- owned by a carried program: its transfers CPI into that program;
- owned by a supported program whose slot holds the placeholder: the new
  `SettlementError::TokenProgramNotProvided` (36), so a forgotten slot
  reads as itself rather than as a malformed account;
- owned by neither: the existing `SellTokenAccountInvalid` /
  `InvalidBuyTokenAccount`, unchanged.

The slots are positional. Each holds its own program or the placeholder;
anything else, swapping the two included, is `IncorrectProgramId`.

`FINALIZE_FIXED_ACCOUNTS` becomes 4, which `push_destinations` follows on
its own. `CreateBuffer` and `ReclaimBuffer` keep their single
`token_program` account: each works on one mint at a time, so there is
nothing to mix.

The settle benchmarks each gain one account, 34 transaction bytes, and
50-170 CU. The one- and two-unit drift on the unrelated create/reclaim
lines is codegen, not behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kaze-cow
kaze-cow force-pushed the kaze/sc-153-token-2022-settle branch from 7af181d to 35bb3fe Compare August 28, 2026 08:29
kaze-cow added a commit that referenced this pull request Sep 4, 2026
The Token-2022 paths are worth little if they only hold for the handful of
cases someone thought to write twice, so instead of a parallel suite this
reruns the suite that already exists against the second program.

`common::also_under_token_2022!(some_test)` sits in front of a test and
generates `some_test_token_2022`, which runs the same body with Token-2022 as
the thread-local active program. Nothing in the body changes: the token helpers
build against the program that owns the account they are handed, and
`payer_signed_tx` / `signed_tx` repoint the legacy program id in every
instruction they assemble. 19 tests are covered this way, across buffer
creation and reclamation -- error paths as much as happy ones.

`CreateBuffer` and `ReclaimBuffer` name their token program as an account, so
repointing reaches them. `common::buffer::ensure_buffer_exists` reads the
program off the mint instead of taking it as a parameter, a buffer being a
token account of its mint and so bound to the mint's own program; that also
lets one test build buffers under both programs at once, which is what
`ensure_buffer_exists_for` used to be for.

Naming the test in front of it, rather than wrapping the body, keeps the
indentation and makes a stale name a compile error instead of a test that
quietly stopped being generated. A test that can only hold under one program
goes without and says why -- the one pinned to the legacy native mint.

Three tests are Token-2022-only, covering what has no legacy analogue -- a
buffer for a mint with a `TransferFeeConfig`, which needs a `TransferFeeAmount`
on every account holding it and so must be longer than the base layout:

- `creates_buffer_sized_for_a_mint_with_extensions`
- `recreating_an_extension_mint_buffer_is_idempotent`
- `reclaims_a_buffer_sized_for_an_extension_mint`

`bench-report.json` gains the CU, account, and transaction-byte readings for
every generated test. It also picks up the `remove_solver` rows and a handful
of single-digit CU corrections that were already owed from merging main, which
the report on this branch's base had not been regenerated for.

The settlement pair is deliberately left out. `BeginSettle` and `FinalizeSettle`
name their token programs by value rather than by account, so rerunning their
tests needs the slot handling from #128 and belongs on that branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kaze-cow added a commit that referenced this pull request Sep 7, 2026
Adds the most basic level of Token-2022 support which widens the
accepted token programs and focuses on dealing with the edge cases of
`CreateBuffers` and `ReclaimBuffers`.

## What changes

`CreateBuffer` and `ReclaimBuffer` each take a `token_program` account
and, until now, rejected anything that wasn't the legacy SPL Token
program. They now accept Token-2022 too and issue all of their CPIs —
`InitializeAccount3`, `CloseAccount` — against whichever of the two they
were handed.

Token2022 accounts may have dynamic length. Previously, a buffer could
only hold 165 bytes (the length of SPL token account), but now it is now
allocated at the length its mint actually needs. `GetAccountDataSize` is
used to verify the length of the token required before creating it.

For `CreateBuffers` creation of token accounts, We looked into the
possibility of making the account longer than it needs to be and then
shrinking it, but this doesn't work well because a extra CPI call is
necessary to shrink the account back down to the correct size. But this
requires a CPI call to `Reallocate` on the token program, and that is
more expensive than just getting hte length.

There is an early return on `token_account_len` which allows for
skipping the `GetTokenAccountLength` CPI call. We originally did the
early return if the Mint size was th ebase size (the logic being that a
base size mint is either a canonical SPL token *OR* a token2022 mint
with no required extensions), but since the vast majority of token2022
mints *DO* actually have extensions, there is very little benefit in
having this broader check. So we decided to only do the early return for
SPL tokens.

## Library Handling Changes

`spl-token-2022-interface = "3"`, a library [published by
anza-team](https://crates.io/crates/spl-token-2022-interface), and
`pinocchio-token-2022 = "0.4"`, another library [published by
anza-team](https://crates.io/crates/pinocchio-token-2022), are added for
hopefully obvious reasons.

~~`spl-token-interface` was removed from the settlement program because
it is no longer needed (later it will also be removed from test-cli,
eliminating it as a direct dependency from the repo).~~ ended up being
re-added after adding new tests because apparently a lot of mint
creation utilities need to be recreated without it.

`pinocchio-token` is bumped to `0.7` in order to gain access to the
`invoke_with_unverified_program(token_program)` instruction builder
function (prior to this release, there was no way to specify an
alternative program). Its also the version that `pinnochio-token-2022`
transitively depends on.

Most of the functions in both `pinnochio-token` and
`pinnochio-token-2022` are close to identical. For now most of the
interfaces continue to use `pinnochio-token` because we never actually
work with token 2022 tokens directly and the interfaces usually have
slightly less dependencies (ex. not specifying the extension
information).

## Out of Scope

`BeginSettle` / `FinalizeSettle` require a different methodology to
support simultaneous settlement from both token programs, so those
follow in #128.

`test-cli` is covered separately in #134 .

The integration tests for both this PR and #128 are in #121, as we want
to expand coverage with 2022 across as many tests as possible.

At this time, only one token program can be supplied to the buffer
functions. Two separate calls to `CreateBuffers` is required if it is
necessary to create buffers for tokens on two separate prgorams.

## Compute cost

`bench-report.json` is regenerated. The buffer instructions shift by
roughly +0.2% to +0.4% from the added dispatch
(`reclaim_buffer/max_buffers_in_one_instruction` 136,501 → 137,046 is
the largest); the one- and two-unit drift on the unrelated settle and
transfer-authority lines is codegen, not behaviour.

## Test Plan

Verify the methodology. In particular, it would be good to verify the
library dependency status as described above, because it is a bit
awckward.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com>
Base automatically changed from kaze/sc-153-token-2022-program to main September 7, 2026 13:42
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.

1 participant