Running Solana

The four EVM assumptions Solana breaks — confirmation depth, slots, the drop rule and receipt logs — plus the fee-account mechanics and the routine that keeps SPL fees collectable.

13 min readUpdated 6 August 2026solana, spl, execution, fees, confirmations, jupiter

Solana is chain id 1399811149 in this platform, virtual machine SVM, one aggregator (Jupiter), and the newest surface in the addon. Everything an operator already knows about the EVM path is nearly true here, and the four places it is not are the four places a Solana install goes quietly wrong.

Solana has no EIP-155 chain id, it has a genesis hash, and every registry that needs a number invents a different one. This platform uses 1399811149 for a concrete reason: chainId is a signed INTEGER on eleven of the twelve DEX models, so a value like LI.FI's 1151111081099710 would saturate to 2147483647 on every row and collide with every other chain that overflowed.

It is never sent anywhere. No RPC sees it, no wallet is asked to switch to it, no signature commits to it. It is this platform's primary key for "Solana mainnet" and nothing more.

Before anything else: the RPC endpoint

Resolution mirrors the EVM ladder exactly, so there is one rule to learn:

  1. dexChain.rpcUrlOverride — the row, set on Admin → Swap → Chains
  2. APP_DEX_RPC_SOLANA — this addon's own variable
  3. SOL_MAINNET_RPC — the ecosystem's, so a chain you already scan for deposits needs no new variable

clusterApiUrl() is deliberately not used. It returns Solana Labs' public endpoint, which is rate-limited well below production traffic and documented as not for production — and, worse, it would make the addon appear to work on an install with no RPC configured at all. A missing endpoint is a visible 503 naming the variable to set, exactly as on EVM.

Reads use the confirmed commitment; settlement uses finalized. That asymmetry is the same one EVM expresses as requiredConfirmations: reading is cheap and can be redone, settling is not.

The four assumptions Solana breaks

1. There is no confirmation depth

headBlock - blockNumber >= requiredConfirmations is the EVM rule, and it has no Solana equivalent. A slot is finalized or it is not — finality is a supermajority vote, not an accumulation of work.

Rather than forking the shared decision function, the observation synthesises the number that function already knows how to read: a finalized transaction reports headBlock = slot + requiredConfirmations, which satisfies the depth test by construction. One place reconciles the two models, and it does so by producing the EVM shape.

Two consequences on screen:

  • dexChain.requiredConfirmations on the Solana row means nothing. It is set to 1 because the column is NOT NULL; every SVM path ignores it in favour of the finalized commitment. Raising it does not make settlement safer.
  • confirmations on a Solana swap row is not a count of anything. Solana's own confirmations === null is how it says rooted — finalized and no longer counted. Reading that null as zero is how a finalized transaction would never confirm, which is why the observation treats it as final.

2. Slots are not block numbers

Solana skips slots: a slot whose leader produces nothing produces no block. So slot numbers are not contiguous, and the difference between two slots is not a count of blocks.

Anything computing "how far behind are we" from a slot delta is computing a number with no meaning. If you need lag on Solana, use timestamps. This is exactly why the confirmation depth above is synthesised from the finality answer rather than measured from a slot arithmetic.

3. The drop rule has no nonce to work with

EVM decides a transaction is gone by comparing the sender's current nonce against the transaction's. Solana has no account nonce — replay protection is the recent blockhash, and a transaction becomes permanently invalid the moment lastValidBlockHeight passes.

That is a better signal than the heuristic it replaces, and the sweeper is built to use it: lastValidBlockHeight is stamped on the quote's raw payload by the Jupiter adapter, and expiry is mapped onto the nonce pair so the shared decision function needs no SVM branch.

That raw payload is the adapter's in-memory result and nothing persists it. The sweeper looks for the height in three places on the swap row — lastValidBlockHeight, routeSummary.raw and metadata — and dexSwap has no lastValidBlockHeight column and no routeSummary column, while its metadata column is written by no code path. The lookup therefore always returns null, the observation never reports expired, and a Solana row is resolved by age exactly as a TRON or TON row is.

The visible result on Admin → Swap → Activity → Swaps:

  • An unseen Solana row is DROPPED with reason not-seen, not nonce-consumed, and only once it is older than dexDropAfterMs.
  • The 30-minute drop window governs Solana too, exactly as it governs TRON and TON. Do not tell a customer that an expired swap will be marked dropped on the next sweep — it waits out the window like every other chain the sweeper has no nonce for.
  • Replacement is refused outright. REPLACED is a nonce mechanic; there is no equivalent, so a Solana row never reaches that state.

See Swap history for the full state machine.

4. A failure is an err object, not status: 0

There is no status: 1 | 0 on a Solana result. There is an err field that is null on success.

Non-null means the transaction was included and its instructions failed. That is the same thing an EVM status: 0 receipt means, the user paid for it either way, and it is charged for identically — the row becomes REVERTED with reason reverted. It is emphatically not "not found", and a support answer that treats it as one will tell a customer their swap never happened when it did and cost them.

One trap worth knowing because it fails silently: fetching the full transaction requires maxSupportedTransactionVersion: 0. Without it, getTransaction returns null for every versioned transaction rather than erroring — and every Jupiter swap is versioned, because the route needs address lookup tables to fit inside Solana's 1232-byte limit. The symptom would be swaps that finalize and then look as though they were never seen.

And a fifth: there are no receipt logs

There is no logs array of {topics, data}. The equivalent is preTokenBalances / postTokenBalances — token balance deltas, which are a better source for fee attribution than EVM logs, because they are the net effect rather than the events a contract claimed.

The observation collects both arrays and hands them to the accrual writer.

The accrual writer's evidence matcher looks for an ERC-20 Transfer log matching (token, recipient). It does not read the balance-delta shape, so on Solana it finds nothing to prove the amount with and falls back to the declared arithmetic path: the row is written from the quote's estimatedFeeAmountRaw with verification: ESTIMATED rather than RECEIPT, and logIndex null.

That is a labelling difference, not a lost fee — the amount is still recorded and still sweepable. But when you read the fee ledger, expect Solana rows to sit in the inferred column rather than the proved one. See Fees and revenue.

Network fee is recorded the same way: the flat lamport fee goes into gasUsed and effectiveGasPrice is left null, precisely so nothing multiplies two figures into a fee that was never charged. Solana charges per signature plus a priority bid, not gas × price.

The fee account, in the detail an operator needs

The concept is covered on Wallets and Providers. This is the mechanism.

One account per (owner, mint)

An SPL token cannot be held by a wallet. It is held by an Associated Token Account the wallet owns, and there is exactly one canonical such account per (owner, mint) pair. So on Solana:

  • dexChain.feeRecipient stores the owner wallet.
  • Every mint fees can arrive in needs its own account under that wallet.
  • Each account must exist before anything can arrive, and creating one costs rent — about 0.00204 SOL, refundable if the account is ever closed.

The address is derived, never stored. An ATA is a program-derived address: given the owner and the mint there is exactly one answer, computable offline. Storing it would create a second source of truth that can disagree with the chain, and the disagreement would send fees wherever the stale row said.

Which token program owns a mint decides the derivation, so it is read from the mint account rather than assumed — the owner field of a mint account is the token program. Assuming the classic program produces a valid-looking address the swap cannot pay into. A growing share of new mints are Token-2022, so this is the kind of default that works for a year and then does not.

The derivation is done with allowOwnerOffCurve: true on purpose. An owner that is a PDA — a multisig or a program-controlled treasury, which is what a careful operator uses — is off the ed25519 curve, and the default derivation throws for those. Refusing to derive for a multisig treasury would push operators toward a bare keypair, which is the less safe choice.

Jupiter takes the token account, not the wallet

Jupiter's feeAccount parameter wants the token account. Passing a wallet address is rejected; passing an account that does not exist yet is worse, because some route shapes accept it and silently drop the fee.

Jupiter also requires platformFeeBps and feeAccount together — sending the bps without the account is not a partial success. So the adapter decides once and records the decision on the quote:

  • The fee applies only when the quote is not price-only, feeBps > 0, and an account address was resolved.
  • When it cannot be resolved, the quote is served at zero fee. The adapter names the reason as feeAccountMissingReason, but only on its in-memory raw payload — and nothing persists that payload on a Solana route, because dexQuote.routeSummary is written by no code path and complianceSnapshot carries raw (as poolState) only for a direct-pool route, which a Jupiter quote is not. No column records why the fee was dropped. The row's feeBps keeps the configured rate either way, so it cannot tell you; the field that moves is feeSide, which the adapter leaves null when the fee did not apply. See The quote log.
  • An unreachable RPC counts as unresolved. We cannot prove the account exists, and sending an unverified account would convert a transient outage into revenue that looks collected and is not.

The mint that matters is the buy-side one: Jupiter takes platformFeeBps from the output mint on an exact-in swap, so the account you need is for the token the user receives.

Building the creation transaction

POST/api/admin/dex/wallet/fee-accountpermission: edit.dex.chain
Returns an UNSIGNED base64 v0 transaction that creates the associated token account for a mint under the chain's fee recipient.

Body: chainId, mint (base58, case-significant), payer.

Nothing is signed server-side. The server produces bytes and a wallet the operator controls signs them — the same contract every other write in this addon follows, and the reason this route can exist at all without breaking the non-custody guarantee.

Four refusals, each named:

  • An EVM chain — its fee recipient receives every token with no setup, and returning an empty success would leave you waiting for a transaction that is never coming.
  • An invalid mint or payer address.
  • No fee recipient set for the chain, so there is no wallet to create the account under.
  • A mint that is not in the chain's token list — a fee account for a token nobody can swap collects nothing.

The response carries transaction (base64, unsigned, v0), feeAccount, lastValidBlockHeight, owner, mint and symbol.

Payer and owner may differ, deliberately. A treasury owned by a multisig cannot pay for its own account creation without the multisig executing a transaction. The operator clicking this funds the rent; the multisig owns the result. Refusing that would push operators toward a bare keypair treasury.

It is built as a v0 transaction even though one instruction would fit in a legacy one — the client has exactly one deserialise-and-sign routine, and a legacy transaction arriving at a VersionedTransaction.deserialize call fails in a way that reads as a corrupt payload.

/admin/dex/wallet reports the per-mint accounts — address, exists, token program, rent — and rolls them into a Token accounts meter and an ACCOUNTS_MISSING verdict. It does not render a create action, so the endpoint above has to be called directly today. The readiness half is what the screen is for; the creation half is an API call.

Why getOrCreateAssociatedTokenAccount is a lint error here

It is banned in this tree, along with createMint and mintTo, by both guards — the eslint config and the build-time invariant check. mintToChecked is on the eslint rule's import list too, but the invariant script's pattern is \b(createMint|mintTo|getOrCreateAssociatedTokenAccount)\b, and the word boundary after mintTo cannot match the longer name — so that one identifier trips only one of the two guards.

The reason is one sentence: it SPENDS to create the account, which means some key paid for it, which means some key was held here. This addon holds no key for anybody, and that is enforced rather than intended.

The permitted pair is getAssociatedTokenAddressSync — derives the same address with no signer and no lamports — and createAssociatedTokenAccountInstruction, which builds an instruction somebody else signs. The whole ecosystem Solana service (@b/blockchains/sol) is forbidden for the same reason: it creates wallets, deploys SPL tokens, mints supply and decrypts a master wallet to sign withdrawals. The DEX addon's own read handle exists so that forbidden import has somewhere legitimate to go.

Gas reserve: it is rent, not gas

Solana's minGasReserve is 0.01 SOL, an order of magnitude above what the transaction fee needs, and the excess is deliberate.

A signature costs 5000 lamports plus a priority bid that spikes under load. What dominates is rent exemption: buying a token the user has never held creates an associated token account for them, and an account must hold ~0.00204 SOL to exist at all. A Jupiter route can create more than one. Reserving only the fee would let a user spend to the last lamport and then watch the swap fail because the destination account cannot be created — with the fee already paid.

The routine

  1. Configure the RPC endpoint before switching the chain on. Set APP_DEX_RPC_SOLANA, or the row override on Admin → Swap → Chains. Without one, the Wallets screen reports UNVERIFIABLE rather than ready — which is not the same as broken and not the same as fine.

  2. Set the fee recipient on Admin → Swap → Chains. On Solana this is the wallet that will own the fee accounts, not an account itself.

  3. Open /admin/dex/wallet and read the Solana row. The verdict is one of READY, NO_RECIPIENT, ACCOUNTS_MISSING or UNVERIFIABLE, and the per-mint list underneath names every allowlisted token with its derived account address and whether it exists. The list is rendered even when everything exists — "which accounts do I have" is a question you ask before adding a token.

  4. Create the accounts that are missing, one per mint, using the endpoint above and your own wallet to sign. Budget ~0.00204 SOL of rent each, refundable on close.

  5. Do it again after every new SPL listing. A new token means a new mint means a new account, and nothing about the fee recipient changes to tell you. Tokens is where a listing happens; this step is the half that is easy to forget.

  6. Re-check after any change to the chain's configuration — a changed fee recipient invalidates every derived account at once, because the derivation takes the owner as an input.

Swaps succeed. Users are happy. The quote quietly carried no platform fee, so there is no accrual and nothing to reconcile against — the fee ledger simply has fewer rows than you expected, on one chain, for one token. Nothing errors and nothing is logged as a failure.

The Wallets screen is the only place that answers it before a revenue report does.