Repository navigation
solana-orderbook: report the buy token account rent in quotes - #5055
Conversation
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.
|
Reminder: Please consider backward compatibility when modifying the API specification.
Caused by: |
…lacement-costs-in-the-quote-response
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.
`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
|
Claude finished @tilacog's task in 5m 46s —— View job Review: solana-orderbook rent-in-quote
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:
Points already acknowledged in the PR description (noted for reviewer/frontend awareness, not asking for changes): the new CI: no failures (3 passed, 2 in progress at time of review). |
| /// 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)) | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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>, |
There was a problem hiding this comment.
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. |
| 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" |
There was a problem hiding this comment.
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.
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: 0and 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):receiveritself when it is an associated token account of the buy mint, else the ATA ofreceiver, or offromwithout one — and zero for native SOL buys and for anonymous quotes without areceiver;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;InvalidBuyTokenAccount: the placement's idempotent creation leaves it as is, so the order would never fill. So is areceiverthat is a token account of the buy mint but not its owner's associated one, which the sponsored placement'sCreateIdempotentfails on.getMultipleAccountsreads the recipient, its associated token account, the buy mint and the rent sysvar, concurrently with the driver quote.check_mintsstill answers the buy mint's token program from the mint lookup the quote already does.solana-token: newata_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, andreceivable_token_account_ownerfor 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 thatreceiveris 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_payoutTODOs).How to test
cargo nextest run -p solana-orderbook -p solana-token.quote_prices_the_missing_buy_token_account_by_its_mintruns 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_oneruns 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.Related Issues
Linear: SC-471