Skip to content

solana-orderbook: report the buy token account rent in quotes - #5055

Merged
tilacog merged 27 commits into
mainfrom
tiago/sc-471-report-placement-costs-in-the-quote-response
Oct 15, 2026
Merged

tilacog merged 27 commits into
mainfrom
tiago/sc-471-report-placement-costs-in-the-quote-response

Conversation

@tilacog

@tilacog tilacog commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Description

The Solana quote response gains executionCostLamports: the rent of the buy token account when the payout cannot land in an existing one, zero when it can.

Motivation

The quote response had a hardcoded feeAmount: 0 and no field for what an order costs to place, so the UI had nothing to show the user. A missing buy token account is the main cost a placement can carry (about 0.0015 SOL).

Changes

  • executionCostLamports (decimal string, required in the response):
    • zero when the payout lands in an existing account — the receiver itself when it is an associated token account of the buy mint, else the ATA of receiver, or of from without one — and zero for native SOL buys and for anonymous quotes without a receiver;
    • otherwise the rent of the account to create, at the cluster's rent (the rent sysvar, 5,080 lamports per byte on mainnet since SIMD-0437; the SDK's default of 6,960 is above it) and priced from the buy mint itself: a classic account 1,488,440 lamports, a Token-2022 account also pays for the extensions the mint's own extensions require of it (a mint carrying a zero-fee TransferFeeConfig: 1,574,800). Lamports already sitting at the ATA's address come off the rent, as the ATA program only tops a pre-funded account up;
    • the largest rent a settleable mint can need (a Token-2022 mint with fee config, hook and pause extensions: 1,620,520 at mainnet's rent) when the buy mint's program is unknown or the account read fails, priced at the cluster's rent as last read; the SDK's default (2,220,240) prices only the quotes before the first read lands and the ones without a sponsoring RPC.
  • An account at either address that exists but cannot take the payout — frozen, another mint's, requiring memos, or refusing credits outside its confidential balance — is refused with 400 InvalidBuyTokenAccount: the placement's idempotent creation leaves it as is, so the order would never fill. So is a receiver that is a token account of the buy mint but not its owner's associated one, which the sponsored placement's CreateIdempotent fails on.
  • One getMultipleAccounts reads the recipient, its associated token account, the buy mint and the rent sysvar, concurrently with the driver quote. check_mints still answers the buy mint's token program from the mint lookup the quote already does.
  • solana-token: new ata_rent(&Rent, &Account) with the rent of a new associated token account of the given mint under the given rent, max_ata_rent(&Rent) as the ceiling over settleable mints, and receivable_token_account_owner for the ATA derivation. Tests pin the account lengths and derive every quoted value from them.
  • openapi.yml: documents the new field and error, and clarifies that receiver is an associated token account of the buy mint or a wallet paid through its associated token account.

Left out: the network fee (it depends on a solution that doesn't exist at quote time), the order PDA rent, and the pre-existing SDK-default rent in the native payout floor (receivable_native_payout TODOs).

How to test

  • cargo nextest run -p solana-orderbook -p solana-token.
  • Unit tests cover the account matrix — existing, missing, pre-funded and wallet accounts at the recipient and at its ATA, the rent sysvar present and absent — the refusals (frozen, another mint's, a system account with data at the ATA address, a non-associated receiver), the anonymous quote with a receiver, the zero cases, and the max-rent fallback. quote_prices_the_missing_buy_token_account_by_its_mint runs the full quote route with a sponsoring mock over classic SPL, Token-2022 and zero-fee Token-2022 mints at mainnet's rent.
  • the_rent_as_last_read_prices_the_reads_without_one runs one quote with the read skipped before any sysvar landed (the SDK's default), then a read that lands mainnet's sysvar, one that misses it, one whose RPC fails and one skipped again, all priced at mainnet's rent.
  • Rollout: response-only and additive, plus one new 400 for quotes that could never fill. The frontend still has to read the field (needs a product call on whether to show it).

Related Issues

Linear: SC-471

The quote response gains `executionCostLamports`: the rent of the buy
token account when it is missing, zero when it exists, anonymous
quotes and native SOL buys. The network fee is left out, since it
depends on a solution that does not exist at quote time. Without the
sponsoring RPC, or when the account cannot be read, the rent of a
missing Token-2022 account is reported, the costlier kind.

Adds `ata_rent` to solana-token for the rent of each token program's
associated token account.
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

Reminder: Please consider backward compatibility when modifying the API specification.
If breaking changes are unavoidable, ensure:

  • You explicitly pointed out breaking changes.
  • You communicate the changes to affected teams (at least Frontend team and SAFE team).
  • You provide proper versioning and migration mechanisms.

Caused by:

tilacog added 13 commits October 8, 2026 11:40
The SDK's default rent is 6,960 lamports per byte, the live clusters
charge 5,080 since SIMD-0437, and the ATA program reads the rent sysvar.
A classic account was quoted at 2,039,280 lamports for a 1,488,440 charge.
The quote's account read fetches the rent sysvar in the same batch and
`ata_rent` and `max_ata_rent` price under the rent given; the paths that
cannot read it keep the SDK's default, above the cluster's.
A frozen token account, another mint's, or one refusing plain credits
counted as missing and was charged the rent of a new account. On chain the
idempotent creation leaves such an account as is, so no rent is paid and
the order never fills: the autopilot and the driver drop it. The quote now
answers `InvalidBuyTokenAccount`, as `InvalidNativeBuy` does for a wallet
that cannot take a native payout.
…rent

The ATA program tops a pre-funded system account up to the rent-exempt
minimum instead of paying it in full, so the quote charged more than the
creation would. The lamports already there come off the quoted rent.
A sponsored placement proves the buy token account receivable with the
ATA program's idempotent creation, which fails on any account but the
owner's associated one. A quote naming an auxiliary token account as
`receiver` answered zero for an order that cannot be placed; it is now
refused as `InvalidBuyTokenAccount`. `solana-token` exposes the owner of
a receivable token account for the derivation.
A sponsored placement creates the buy token account for `from` as well as
for a custom receiver, so the openapi no longer singles the receiver out.
The api test's mock answers any address, so it proves the mint-to-rent
mapping, not the token program choice its doc claimed. The unread-account
fixture is a complete answer that would price zero if it were read.
Doc comments that restated a function's name or a test's rows go; the
ones left say why: the mock's positional answers, the ceiling an SPL
Token mint pays unread, the refusal of a receivable non-associated
account, the top-up of a pre-funded address.
The DTO field docs match their one-line neighbours; the openapi carries the full semantics. The buy account rent doc dropped its recap of the three fallback returns, which name `max_ata_rent` and the SDK's default rent themselves.
`check_mints` answered `None` both for a native SOL buy and for a mint lookup that did not run, and `buy_account_rent` told them apart by re-checking the buy token. A `BuyProgram` enum names `Native`, `Known` and `Unread`, and `check_mints` takes the optional sponsoring itself.
@linear-code

linear-code Bot commented Oct 8, 2026

Copy link
Copy Markdown

BE-311

`buy_account_rent` keeps the early outs and the RPC read; `recipient_receives`, `read_ata_rent` and `ata_rent_owed` judge the recipient, price the mint and settle the associated token account's address.
`recipient_receives`, `read_ata_rent` and `ata_rent_owed` are pure, so each gets a table over plain accounts with no RPC mock; the frozen account fixture now builds on the shared token account constructor.
An editor accident turned `is_some()` into `is_The()` and the doc's "The owner" into "some owner", and the test commit picked it up. This restores both.
…lacement-costs-in-the-quote-response

# Conflicts:
#	crates/solana-token/src/lib.rs
@tilacog
tilacog marked this pull request as ready for review October 8, 2026 18:21
@tilacog
tilacog requested a review from a team as a code owner October 8, 2026 18:21
@tilacog
tilacog requested a review from squadgazzz October 8, 2026 18:21
@claude

claude Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @tilacog's task in 5m 46s —— View job


Review: solana-orderbook rent-in-quote

  • Read the diff against origin/main
  • Review solana-token helpers (ata_rent, max_ata_rent, receivable_token_account_owner)
  • Review quote/mod.rs logic (account matrix, refusals, fallbacks)
  • Review dto.rs and openapi.yml
  • Cross-check arithmetic and test coverage

No blocking issues found. This is a careful, thoroughly-tested change — I did not find a correctness defect worth a code change, so I'm not leaving inline comments.

What I checked and am satisfied with:

  • Account matrix & refusals. recipient_receives / ata_rent_owed correctly distinguish: a receivable ATA (cost 0), a receivable non-ATA token account at the recipient (refused — CreateIdempotent would fail), a frozen / wrong-mint / memo-required account (refused), a pre-funded system account at the ATA address (rent minus balance, via saturating_sub), and a system account with data at the ATA address (refused). Because the ATA address is a PDA only the ATA program can allocate, treating a receivable token account there as the recipient's ATA without re-deriving is sound.
  • Arithmetic. The pinned numbers line up at mainnet's 5,080 lamports/byte: classic (165+128)·5080 = 1,488,440; plain Token-2022 (170+128)·5080 = 1,513,840; zero-fee Token-2022 (182+128)·5080 = 1,574,800; max_ata_rent at the SDK default (191+128)·6,960 = 2,220,240. Deriving the quoted values from the account lengths in the tests makes these self-checking.
  • Dispatch. BuyProgram cleanly folds native (→ 0), anonymous-without-receiver (→ 0), unknown-program/no-sponsor/read-failure (→ max bound), and known-program (→ precise read). Pubkey::default() as the anonymous from and ENCODED_NATIVE_SOL_TRANSFER as the native buy both resolve consistently with the rest of the crate.
  • Latency. The buy-account read runs under tokio::join! with the driver quote, so it adds no round-trip on the happy path; the rent error is surfaced before the NoLiquidity mapping, which is the right precedence.
  • DTO / OpenAPI. Response-only and additive; executionCostLamports serializes as a decimal string matching the schema.

Points already acknowledged in the PR description (noted for reviewer/frontend awareness, not asking for changes): the new 400 InvalidBuyTokenAccount now fires at quote time for accounts that could never fill; without sponsoring every non-native quote reports the 2,220,240 upper bound regardless of whether the user already holds the ATA; and the buy mint is read a second time in buy_account_rent. All three are called out in the description, and the frontend gating is flagged as a product call.

CI: no failures (3 passed, 2 in progress at time of review).
· branch tiago/sc-471-report-placement-costs-in-the-quote-response

Comment thread crates/solana-orderbook/openapi.yml Outdated
Comment thread crates/solana-orderbook/src/infra/api/routes/quote/mod.rs Outdated
Comment on lines +213 to +229
/// The rent-exempt minimum under `rent`, in lamports, of a new associated
/// token account of the mint at `mint`, `None` when it is no mint of either
/// token program. A Token-2022 one carries the immutable owner extension and
/// the account extensions the mint's own extensions require.
pub fn ata_rent(rent: &Rent, mint: &Account) -> Option<u64> {
let len = match TokenProgram::try_from(&mint.owner).ok()? {
// A classic mint is exactly 82 bytes; any other account of the
// program is a token account or a multisig.
TokenProgram::SplToken if mint.data.len() == Mint::LEN => TokenAccount::LEN,
TokenProgram::SplToken => return None,
TokenProgram::Token2022 => {
let mint = StateWithExtensions::<Mint>::unpack(&mint.data).ok()?;
token_2022_ata_len(&mint.get_extension_types().ok()?)
}
};
Some(rent.minimum_balance(len))
}

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.

Both fallbacks price at the SDK default of 6960 per byte, and mainnet runs at 5080 AFAIK, so a fallback answer for a USDC buy is 2220240 against a real 1488440, 49% over. Maybe we just have to keep the last read value in cache?

@tilacog tilacog Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8d5b875

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d0020c9: Sponsoring now keeps the rent sysvar of the last successful account read, and a failed or skipped read charges max_ata_rent at that rent. The SDK default only prices quotes before the first read lands.

Comment thread crates/solana-orderbook/src/infra/api/routes/quote/mod.rs Outdated
Comment thread crates/solana-orderbook/src/infra/api/routes/quote/dto.rs Outdated
The quote refused a `receiver` that is a token account of the buy mint but
not its owner's associated one. Only the sponsored placement's idempotent
creation needs the associated address: placement accepts any buy token
account the intent names and the autopilot checks it with
`receivable_token_account` alone, so such an order fills. The quote now
prices it at zero like any other receivable account, and `solana-token`
drops the owner lookup the refusal needed.
An anonymous quote, `from` zero without a `receiver`, answered zero. The UI
quotes before the wallet connects, so the user saw no cost at all. The EVM
orderbook never under-reports there: an unverified quote keeps the full fee.
The quote now reads only the buy mint and the rent sysvar and charges the
rent of the account it cannot name.
The EVM orderbook keeps units out of field names and states them in the
description, and the UI shows the account rent apart from the network fee,
so the field names what it holds: the buy token account's rent.
The UI shows the network fee apart from the account rent, and the quote
reported only the rent. The fee is the estimate BE-366 nets out of the
quote: the base fee of one signature plus the runtime's compute unit ceiling
at a fixed 1,000 micro-lamports per unit, as the solution does not exist at
quote time. The price stays a constant until BE-366 reads it from config.
Both fallbacks of the buy token account read priced the account at the
SDK's default rent, 6960 lamports per byte, while mainnet charges 5080
since SIMD-0437: a USDC buy quoted unread owed 2220240 lamports against
1488440 read, 49% over. Sponsoring now keeps the rent sysvar of the last
successful read, and a failed or skipped read charges that; the default
prices only the reads before the first one lands.
/// The verdicts on the mints placement and quoting read so far.
pub mints: solana_token::MintVerdicts,
/// The cluster's rent as last read, see [`Sponsoring::rent`].
pub rent: std::sync::Mutex<Rent>,

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.

This was my suggestion in the thread, so apologies for the back and forth, but after double checking, it only tightens the fallbacks, and those are error paths (mint lookup or account read failed) plus the no-sponsoring setup. Even cached, a USDC fallback answers 1620520 against 1488440, so it stays an upper bound anyway. Maybe just decode the sysvar inline from the batch on the happy path and keep the SDK default for the fallbacks, without the Mutex and read_rent?

/// The network fee of one settlement, in lamports: the base fee of its one
/// signature plus the runtime's compute unit ceiling at a fixed price of
/// 1,000 micro-lamports per unit, as the solution does not exist at quote
/// time. TODO(BE-366): take the price from the quoting config.

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.

#5101 makes the driver's /quote answer networkFee, so BE-366 should take it from there rather than from config. Maybe point the TODO and the openapi text at that, since the formula goes away.

Comment thread crates/solana-orderbook/openapi.yml Outdated
Comment on lines +631 to +648
networkFees:
description: >-
The settlement's network fee, in lamports: the base fee of one
signature plus the runtime's compute unit ceiling at a fixed
compute unit price. An estimate, as the solution does not exist
at quote time.
type: string
example: "6400"
buyTokenAtaCosts:
description: >-
Rent, in lamports at the cluster's rent, of the buy token account
when the placement must create it; zero when the payout lands in
an existing one and for native SOL buys. An anonymous quote,
`from` zero without a `receiver`, names no account to read and is
priced as if it were missing. An upper bound when the account
cannot be read, without sponsoring included.
type: string
example: "1488440"

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.

feeAmount uses the TokenAmount ref, these two could as well.

…read"

This reverts commit d0020c9.

The rent as last read only tightened the fallbacks, and those are error
paths, a failed mint lookup or account read, plus the no-sponsoring
setup. Even at that rent a USDC fallback answered 1620520 lamports
against the 1488440 read, an upper bound either way, so the SDK's
default serves them. The happy path decodes the rent sysvar from the
batch read inline again, without the Mutex.
The driver's `/quote` answers `networkFee` for the quoted route (#5101),
so BE-366 takes the fee from there rather than pricing the compute unit
ceiling at a configured price. The TODO and the openapi text name that
instead of the formula that goes away.
`networkFees` and `buyTokenAtaCosts` are decimal strings of base units
like `feeAmount`, so they reference the same schema instead of spelling
the type out.
@tilacog
tilacog requested a review from squadgazzz October 15, 2026 16:36
@tilacog
tilacog added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 5586ce4 Oct 15, 2026
25 checks passed
@tilacog
tilacog deleted the tiago/sc-471-report-placement-costs-in-the-quote-response branch October 15, 2026 17:30
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants