Skip to main content

sol_parser_sdk/
transaction_cost.rs

1//! Opt-in transaction fee, compute-budget, and relay-tip parsing.
2//!
3//! These functions are intentionally separate from DEX event parsing. Callers
4//! that do not request transaction costs incur no instruction scan.
5
6use serde::{Deserialize, Serialize};
7use solana_sdk::{pubkey, pubkey::Pubkey, transaction::VersionedTransaction};
8use yellowstone_grpc_proto::prelude::{InnerInstruction, Transaction, TransactionStatusMeta};
9
10use crate::rpc_parser::{convert_rpc_to_grpc, ParseError};
11use solana_transaction_status::EncodedConfirmedTransactionWithStatusMeta;
12
13const COMPUTE_BUDGET_PROGRAM_ID: Pubkey = pubkey!("ComputeBudget111111111111111111111111111111");
14const SYSTEM_PROGRAM_ID: Pubkey = pubkey!("11111111111111111111111111111111");
15const MICRO_LAMPORTS_PER_LAMPORT: u128 = 1_000_000;
16const SET_COMPUTE_UNIT_LIMIT_TAG: u8 = 2;
17const SET_COMPUTE_UNIT_PRICE_TAG: u8 = 3;
18const REQUEST_HEAP_FRAME_TAG: u8 = 1;
19const SET_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_TAG: u8 = 4;
20const SYSTEM_TRANSFER_TAG: [u8; 4] = 2u32.to_le_bytes();
21
22/// SWQoS providers supported by `sol-trade-sdk`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub enum SwqosProvider {
25    Jito,
26    NextBlock,
27    ZeroSlot,
28    Temporal,
29    Bloxroute,
30    Node1,
31    FlashBlock,
32    BlockRazor,
33    Astralane,
34    Stellium,
35    Lightspeed,
36    Soyas,
37    Speedlanding,
38    Helius,
39    Solami,
40    LunarLander,
41    Glaive,
42}
43
44/// A provider and all tip accounts accepted by that provider.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct SwqosTipAccountGroup {
47    pub provider: SwqosProvider,
48    pub accounts: &'static [Pubkey],
49}
50
51/// Official Jito block-engine tip accounts used by `sol-trade-sdk`.
52pub const JITO_TIP_ACCOUNTS: &[Pubkey] = &[
53    pubkey!("96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5"),
54    pubkey!("HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe"),
55    pubkey!("Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY"),
56    pubkey!("ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49"),
57    pubkey!("DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh"),
58    pubkey!("ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt"),
59    pubkey!("DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL"),
60    pubkey!("3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT"),
61];
62
63pub const HELIUS_TIP_ACCOUNTS: &[Pubkey] = &[
64    pubkey!("4ACfpUFoaSD9bfPdeu6DBt89gB6ENTeHBXCAi87NhDEE"),
65    pubkey!("D2L6yPZ2FmmmTKPgzaMKdhu6EWZcTpLy1Vhx8uvZe7NZ"),
66    pubkey!("9bnz4RShgq1hAnLnZbP8kbgBg1kEmcJBYQq3gQbmnSta"),
67    pubkey!("5VY91ws6B2hMmBFRsXkoAAdsPHBJwRfBht4DXox3xkwn"),
68    pubkey!("2nyhqdwKcJZR2vcqCyrYsaPVdAnFoJjiksCXJ7hfEYgD"),
69    pubkey!("2q5pghRs6arqVjRvT5gfgWfWcHWmw1ZuCzphgd5KfWGJ"),
70    pubkey!("wyvPkWjVZz1M8fHQnMMCDTQDbkManefNNhweYk5WkcF"),
71    pubkey!("3KCKozbAaF75qEU33jtzozcJ29yJuaLJTy2jFdzUY8bT"),
72    pubkey!("4vieeGHPYPG2MmyPRcYjdiDmmhN3ww7hsFNap8pVN3Ey"),
73    pubkey!("4TQLFNWK8AovT1gFvda5jfw2oJeRMKEmw7aH6MGBJ3or"),
74];
75
76pub const NEXTBLOCK_TIP_ACCOUNTS: &[Pubkey] = &[
77    pubkey!("NextbLoCkVtMGcV47JzewQdvBpLqT9TxQFozQkN98pE"),
78    pubkey!("NexTbLoCkWykbLuB1NkjXgFWkX9oAtcoagQegygXXA2"),
79    pubkey!("NeXTBLoCKs9F1y5PJS9CKrFNNLU1keHW71rfh7KgA1X"),
80    pubkey!("NexTBLockJYZ7QD7p2byrUa6df8ndV2WSd8GkbWqfbb"),
81    pubkey!("neXtBLock1LeC67jYd1QdAa32kbVeubsfPNTJC1V5At"),
82    pubkey!("nEXTBLockYgngeRmRrjDV31mGSekVPqZoMGhQEZtPVG"),
83    pubkey!("NEXTbLoCkB51HpLBLojQfpyVAMorm3zzKg7w9NFdqid"),
84    pubkey!("nextBLoCkPMgmG8ZgJtABeScP35qLa2AMCNKntAP7Xc"),
85];
86
87pub const ZEROSLOT_TIP_ACCOUNTS: &[Pubkey] = &[
88    pubkey!("Eb2KpSC8uMt9GmzyAEm5Eb1AAAgTjRaXWFjKyFXHZxF3"),
89    pubkey!("FCjUJZ1qozm1e8romw216qyfQMaaWKxWsuySnumVCCNe"),
90    pubkey!("ENxTEjSQ1YabmUpXAdCgevnHQ9MHdLv8tzFiuiYJqa13"),
91    pubkey!("6rYLG55Q9RpsPGvqdPNJs4z5WTxJVatMB8zV3WJhs5EK"),
92    pubkey!("Cix2bHfqPcKcM233mzxbLk14kSggUUiz2A87fJtGivXr"),
93];
94
95pub const NOZOMI_TIP_ACCOUNTS: &[Pubkey] = &[
96    pubkey!("TEMPaMeCRFAS9EKF53Jd6KpHxgL47uWLcpFArU1Fanq"),
97    pubkey!("noz3jAjPiHuBPqiSPkkugaJDkJscPuRhYnSpbi8UvC4"),
98    pubkey!("noz3str9KXfpKknefHji8L1mPgimezaiUyCHYMDv1GE"),
99    pubkey!("noz6uoYCDijhu1V7cutCpwxNiSovEwLdRHPwmgCGDNo"),
100    pubkey!("noz9EPNcT7WH6Sou3sr3GGjHQYVkN3DNirpbvDkv9YJ"),
101    pubkey!("nozc5yT15LazbLTFVZzoNZCwjh3yUtW86LoUyqsBu4L"),
102    pubkey!("nozFrhfnNGoyqwVuwPAW4aaGqempx4PU6g6D9CJMv7Z"),
103    pubkey!("nozievPk7HyK1Rqy1MPJwVQ7qQg2QoJGyP71oeDwbsu"),
104    pubkey!("noznbgwYnBLDHu8wcQVCEw6kDrXkPdKkydGJGNXGvL7"),
105    pubkey!("nozNVWs5N8mgzuD3qigrCG2UoKxZttxzZ85pvAQVrbP"),
106    pubkey!("nozpEGbwx4BcGp6pvEdAh1JoC2CQGZdU6HbNP1v2p6P"),
107    pubkey!("nozrhjhkCr3zXT3BiT4WCodYCUFeQvcdUkM7MqhKqge"),
108    pubkey!("nozrwQtWhEdrA6W8dkbt9gnUaMs52PdAv5byipnadq3"),
109    pubkey!("nozUacTVWub3cL4mJmGCYjKZTnE9RbdY5AP46iQgbPJ"),
110    pubkey!("nozWCyTPppJjRuw2fpzDhhWbW355fzosWSzrrMYB1Qk"),
111    pubkey!("nozWNju6dY353eMkMqURqwQEoM3SFgEKC6psLCSfUne"),
112    pubkey!("nozxNBgWohjR75vdspfxR5H9ceC7XXH99xpxhVGt3Bb"),
113];
114
115pub const BLOX_TIP_ACCOUNTS: &[Pubkey] = &[
116    pubkey!("HWEoBxYs7ssKuudEjzjmpfJVX7Dvi7wescFsVx2L5yoY"),
117    pubkey!("95cfoy472fcQHaw4tPGBTKpn6ZQnfEPfBgDQx6gcRmRg"),
118    pubkey!("3UQUKjhMKaY2S6bjcQD6yHB7utcZt5bfarRCmctpRtUd"),
119    pubkey!("FogxVNs6Mm2w9rnGL1vkARSwJxvLE8mujTv3LK8RnUhF"),
120];
121
122pub const NODE1_TIP_ACCOUNTS: &[Pubkey] = &[
123    pubkey!("node1PqAa3BWWzUnTHVbw8NJHC874zn9ngAkXjgWEej"),
124    pubkey!("node1UzzTxAAeBTpfZkQPJXBAqixsbdth11ba1NXLBG"),
125    pubkey!("node1Qm1bV4fwYnCurP8otJ9s5yrkPq7SPZ5uhj3Tsv"),
126    pubkey!("node1PUber6SFmSQgvf2ECmXsHP5o3boRSGhvJyPMX1"),
127    pubkey!("node1AyMbeqiVN6eoQzEAwCA6Pk826hrdqdAHR7cdJ3"),
128    pubkey!("node1YtWCoTwwVYTFLfS19zquRQzYX332hs1HEuRBjC"),
129];
130
131pub const FLASHBLOCK_TIP_ACCOUNTS: &[Pubkey] = &[
132    pubkey!("FLaShB3iXXTWE1vu9wQsChUKq3HFtpMAhb8kAh1pf1wi"),
133    pubkey!("FLashhsorBmM9dLpuq6qATawcpqk1Y2aqaZfkd48iT3W"),
134    pubkey!("FLaSHJNm5dWYzEgnHJWWJP5ccu128Mu61NJLxUf7mUXU"),
135    pubkey!("FLaSHR4Vv7sttd6TyDF4yR1bJyAxRwWKbohDytEMu3wL"),
136    pubkey!("FLASHRzANfcAKDuQ3RXv9hbkBy4WVEKDzoAgxJ56DiE4"),
137    pubkey!("FLasHstqx11M8W56zrSEqkCyhMCCpr6ze6Mjdvqope5s"),
138    pubkey!("FLAShWTjcweNT4NSotpjpxAkwxUr2we3eXQGhpTVzRwy"),
139    pubkey!("FLasHXTqrbNvpWFB6grN47HGZfK6pze9HLNTgbukfPSk"),
140    pubkey!("FLAshyAyBcKb39KPxSzXcepiS8iDYUhDGwJcJDPX4g2B"),
141    pubkey!("FLAsHZTRcf3Dy1APaz6j74ebdMC6Xx4g6i9YxjyrDybR"),
142];
143
144pub const BLOCKRAZOR_TIP_ACCOUNTS: &[Pubkey] = &[
145    pubkey!("FjmZZrFvhnqqb9ThCuMVnENaM3JGVuGWNyCAxRJcFpg9"),
146    pubkey!("6No2i3aawzHsjtThw81iq1EXPJN6rh8eSJCLaYZfKDTG"),
147    pubkey!("A9cWowVAiHe9pJfKAj3TJiN9VpbzMUq6E4kEvf5mUT22"),
148    pubkey!("Gywj98ophM7GmkDdaWs4isqZnDdFCW7B46TXmKfvyqSm"),
149    pubkey!("68Pwb4jS7eZATjDfhmTXgRJjCiZmw1L7Huy4HNpnxJ3o"),
150    pubkey!("4ABhJh5rZPjv63RBJBuyWzBK3g9gWMUQdTZP2kiW31V9"),
151    pubkey!("B2M4NG5eyZp5SBQrSdtemzk5TqVuaWGQnowGaCBt8GyM"),
152    pubkey!("5jA59cXMKQqZAVdtopv8q3yyw9SYfiE3vUCbt7p8MfVf"),
153    pubkey!("5YktoWygr1Bp9wiS1xtMtUki1PeYuuzuCF98tqwYxf61"),
154    pubkey!("295Avbam4qGShBYK7E9H5Ldew4B3WyJGmgmXfiWdeeyV"),
155    pubkey!("EDi4rSy2LZgKJX74mbLTFk4mxoTgT6F7HxxzG2HBAFyK"),
156    pubkey!("BnGKHAC386n4Qmv9xtpBVbRaUTKixjBe3oagkPFKtoy6"),
157    pubkey!("Dd7K2Fp7AtoN8xCghKDRmyqr5U169t48Tw5fEd3wT9mq"),
158    pubkey!("AP6qExwrbRgBAVaehg4b5xHENX815sMabtBzUzVB4v8S"),
159];
160
161pub const ASTRALANE_TIP_ACCOUNTS: &[Pubkey] = &[
162    pubkey!("astrazznxsGUhWShqgNtAdfrzP2G83DzcWVJDxwV9bF"),
163    pubkey!("astra4uejePWneqNaJKuFFA8oonqCE1sqF6b45kDMZm"),
164    pubkey!("astra9xWY93QyfG6yM8zwsKsRodscjQ2uU2HKNL5prk"),
165    pubkey!("astraRVUuTHjpwEVvNBeQEgwYx9w9CFyfxjYoobCZhL"),
166    pubkey!("astraEJ2fEj8Xmy6KLG7B3VfbKfsHXhHrNdCQx7iGJK"),
167    pubkey!("astraubkDw81n4LuutzSQ8uzHCv4BhPVhfvTcYv8SKC"),
168    pubkey!("astraZW5GLFefxNPAatceHhYjfA1ciq9gvfEg2S47xk"),
169    pubkey!("astrawVNP4xDBKT7rAdxrLYiTSTdqtUr63fSMduivXK"),
170    pubkey!("AstrA1ejL4UeXC2SBP4cpeEmtcFPZVLxx3XGKXyCW6to"),
171    pubkey!("AsTra79FET4aCKWspPqeSFvjJNyp96SvAnrmyAxqg5b7"),
172    pubkey!("AstrABAu8CBTyuPXpV4eSCJ5fePEPnxN8NqBaPKQ9fHR"),
173    pubkey!("AsTRADtvb6tTmrsqULQ9Wji9PigDMjhfEMza6zkynEvV"),
174    pubkey!("AsTRAEoyMofR3vUPpf9k68Gsfb6ymTZttEtsAbv8Bk4d"),
175    pubkey!("AStrAJv2RN2hKCHxwUMtqmSxgdcNZbihCwc1mCSnG83W"),
176    pubkey!("Astran35aiQUF57XZsmkWMtNCtXGLzs8upfiqXxth2bz"),
177    pubkey!("AStRAnpi6kFrKypragExgeRoJ1QnKH7pbSjLAKQVWUum"),
178    pubkey!("ASTRaoF93eYt73TYvwtsv6fMWHWbGmMUZfVZPo3CRU9C"),
179];
180
181pub const STELLIUM_TIP_ACCOUNTS: &[Pubkey] = &[
182    pubkey!("ste11JV3MLMM7x7EJUM2sXcJC1H7F4jBLnP9a9PG8PH"),
183    pubkey!("ste11MWPjXCRfQryCshzi86SGhuXjF4Lv6xMXD2AoSt"),
184    pubkey!("ste11p5x8tJ53H1NbNQsRBg1YNRd4GcVpxtDw8PBpmb"),
185    pubkey!("ste11p7e2KLYou5bwtt35H7BM6uMdo4pvioGjJXKFcN"),
186    pubkey!("ste11TMV68LMi1BguM4RQujtbNCZvf1sjsASpqgAvSX"),
187];
188
189pub const LIGHTSPEED_TIP_ACCOUNTS: &[Pubkey] = &[
190    pubkey!("53PhM3UTdMQWu5t81wcd35AHGc5xpmHoRjem7GQPvXjA"),
191    pubkey!("9tYF5yPDC1NP8s6diiB3kAX6ZZnva9DM3iDwJkBRarBB"),
192];
193
194pub const SOYAS_TIP_ACCOUNTS: &[Pubkey] = &[
195    pubkey!("soyas4s6L8KWZ8rsSk1mF3d1mQScoTGGAgjk98bF8nP"),
196    pubkey!("soyascXFW5wEEYiwfEmHy2pNwomqzvggJosGVD6TJdY"),
197    pubkey!("soyasDBdKjADwPz3xk82U3TNPRDKEWJj7wWLajNHZ1L"),
198    pubkey!("soyasE2abjBAynmHbGWgEwk4ctBy7JMTUCNrMbjcnyH"),
199    pubkey!("soyasi59njacMUPvo3TM5paHjeK8pYSdovXgFi32gRt"),
200    pubkey!("soyasQYhJxv8uZgWDxhg72td6piAf7XTkoyWHtSATEz"),
201    pubkey!("soyastP66xyYC8XADXZjdMM5BAVGD2YRvz8dwtLsqb8"),
202    pubkey!("soyasvdgUJWYcUCzDxpmjUnNjH7KamXLXTzLwFvdVPE"),
203    pubkey!("soyasvxAunisNxaoRxkKGjNir7KmbwYnr37JmefkX9G"),
204    pubkey!("soyas5doVFUwH8s5zK8gEvCL5KR5ogDmf52LsrJEZ9h"),
205];
206
207pub const SPEEDLANDING_TIP_ACCOUNTS: &[Pubkey] = &[
208    pubkey!("SpEEdz8S1KorkMZqjMUxfxrmWwofmp6ReNP2Nx6CUmq"),
209    pubkey!("SpeeDy3GJM4wcrQmk1itRFWgidvxX4rwjTLMv78wwjE"),
210    pubkey!("SPeEdva37vW8vRtqgYjprQs1g3965icfVN5Rt7SMAyh"),
211    pubkey!("speEdrSEpox5GUfHWcBc7tQjRuSfUin2yvB7qoYvvJh"),
212    pubkey!("SPeEDmkHkN3A2roSZf6aZyEMsmrGqTHKqwP51y2Y4rV"),
213    pubkey!("SpeedLdTJXh2RKpXEaP8JCxkWoUVXhtdPQ1EnxBJMxc"),
214    pubkey!("SpEediGKLbbXndSYTzwmz6Z3NDgHQLDcTDEvGFkSMH9"),
215    pubkey!("speede8xCcUq2Tiv1efXeTuE3k9TDNq8TnGKaKSc6J4"),
216];
217
218pub const SOLAMI_TIP_ACCOUNTS: &[Pubkey] = &[
219    pubkey!("15qWd4huAkoxvhDsHMfpUn27TW1YBYMMJJ2jkAkbeam"),
220    pubkey!("9XuGciSwr5wb7dLTQm91JhuBTvj3GG8WjuRDc3obeam"),
221    pubkey!("kiQioJNyFG7pU36ELLsRKXkeT48kFbk3b6rSgrWbeam"),
222    pubkey!("kjmVhW1UzJrW2sU5bY5NtZ79jpvjSStsj37Pzmabeam"),
223    pubkey!("kREnjPWFpt4AHeY5pijPmyXaCrMnbatUQJo7d3Xbeam"),
224    pubkey!("praRZG6N6MdbsT4EFpKgZJWReZGXQhAMFcH68oCbeam"),
225    pubkey!("SqoKQKU5uwBxovq3R7yEBxFwptc4z7vwoghU3M9beam"),
226    pubkey!("sV72TY66T1RfmDSeHPPbwX6wwJ3bBv5hd4ehJ8tbeam"),
227    pubkey!("swf8MyEeLo7gtRUo27UuJj6naCASUrypU7dbteSbeam"),
228    pubkey!("uiuaQsxA47JybQAVN4FTfYuoEDkMiXV1r591Aewbeam"),
229];
230
231pub const LUNARLANDER_TIP_ACCOUNTS: &[Pubkey] = &[
232    pubkey!("moon17L6BgxXRX5uHKudAmqVF96xia9h8ygcmG2sL3F"),
233    pubkey!("moon26Sek222Md7ZydcAGxoKG832DK36CkLrS3PQY4c"),
234    pubkey!("moon7fwyajcVstMoBnVy7UBcTx87SBtNoGGAaH2Cb8V"),
235    pubkey!("moonBtH9HvLHjLqi9ivyrMVKgFUsSfrz9BwQ9khhn1u"),
236    pubkey!("moonCJg8476LNFLptX1qrK8PdRsA1HD1R6XWyu9MB93"),
237    pubkey!("moonF2sz7qwAtdETnrgxNbjonnhGGjd6r4W4UC9284s"),
238    pubkey!("moonKfftMiGSak3cezvhEqvkPSzwrmQxQHXuspC96yj"),
239    pubkey!("moonQBUKBpkifLcTd78bfxxt4PYLwmJ5admLW6cBBs8"),
240    pubkey!("moonXwpKwoVkMegt5Bc776cSW793X1irL5hHV1vJ3JA"),
241    pubkey!("moonZ6u9E2fgk6eWd82621eLPHt9zuJuYECXAYjMY1C"),
242];
243
244pub const GLAIVE_TIP_ACCOUNTS: &[Pubkey] = &[
245    pubkey!("GLaiv4GMRYQmthatDS98uQT4HoucgxWT8NeJz6oSwxeU"),
246    pubkey!("GLaivL5uPrDpvd1wTtvat38KGqb5WLhEdqQfnmNd3oNr"),
247    pubkey!("GLaivinAWh21NaJMhtExtD5G2gZs1xnvaYVZmwqobWZL"),
248    pubkey!("GLaivJSUL71FcocYa8tks5vpVyYzvaDMHtyrzfQF2ABr"),
249    pubkey!("GLaivRU6eDKrta3p3psFAWPEFLzCjeMHGpPUuQqTjtyv"),
250    pubkey!("GLaivq5dU8qHayz9Qf13LjPfVy3SmUhbmickfGiZdmfh"),
251];
252
253/// Complete provider-aware tip registry mirrored from `sol-trade-sdk`.
254pub const SWQOS_TIP_ACCOUNT_GROUPS: &[SwqosTipAccountGroup] = &[
255    SwqosTipAccountGroup { provider: SwqosProvider::Jito, accounts: JITO_TIP_ACCOUNTS },
256    SwqosTipAccountGroup { provider: SwqosProvider::NextBlock, accounts: NEXTBLOCK_TIP_ACCOUNTS },
257    SwqosTipAccountGroup { provider: SwqosProvider::ZeroSlot, accounts: ZEROSLOT_TIP_ACCOUNTS },
258    SwqosTipAccountGroup { provider: SwqosProvider::Temporal, accounts: NOZOMI_TIP_ACCOUNTS },
259    SwqosTipAccountGroup { provider: SwqosProvider::Bloxroute, accounts: BLOX_TIP_ACCOUNTS },
260    SwqosTipAccountGroup { provider: SwqosProvider::Node1, accounts: NODE1_TIP_ACCOUNTS },
261    SwqosTipAccountGroup { provider: SwqosProvider::FlashBlock, accounts: FLASHBLOCK_TIP_ACCOUNTS },
262    SwqosTipAccountGroup { provider: SwqosProvider::BlockRazor, accounts: BLOCKRAZOR_TIP_ACCOUNTS },
263    SwqosTipAccountGroup { provider: SwqosProvider::Astralane, accounts: ASTRALANE_TIP_ACCOUNTS },
264    SwqosTipAccountGroup { provider: SwqosProvider::Stellium, accounts: STELLIUM_TIP_ACCOUNTS },
265    SwqosTipAccountGroup { provider: SwqosProvider::Lightspeed, accounts: LIGHTSPEED_TIP_ACCOUNTS },
266    SwqosTipAccountGroup { provider: SwqosProvider::Soyas, accounts: SOYAS_TIP_ACCOUNTS },
267    SwqosTipAccountGroup {
268        provider: SwqosProvider::Speedlanding,
269        accounts: SPEEDLANDING_TIP_ACCOUNTS,
270    },
271    SwqosTipAccountGroup { provider: SwqosProvider::Helius, accounts: HELIUS_TIP_ACCOUNTS },
272    SwqosTipAccountGroup { provider: SwqosProvider::Solami, accounts: SOLAMI_TIP_ACCOUNTS },
273    SwqosTipAccountGroup {
274        provider: SwqosProvider::LunarLander,
275        accounts: LUNARLANDER_TIP_ACCOUNTS,
276    },
277    SwqosTipAccountGroup { provider: SwqosProvider::Glaive, accounts: GLAIVE_TIP_ACCOUNTS },
278];
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281pub struct TipPayment {
282    pub provider: SwqosProvider,
283    pub source: Pubkey,
284    pub recipient: Pubkey,
285    pub lamports: u64,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
289pub struct TransactionCost {
290    /// Authoritative transaction fee from status metadata, including priority fee.
291    pub transaction_fee_lamports: Option<u64>,
292    /// Sum of transaction fee and recognized relay tips.
293    pub total_fee_and_tip_lamports: Option<u64>,
294    pub compute_units_consumed: Option<u64>,
295    pub compute_unit_limit: Option<u32>,
296    pub compute_unit_price_micro_lamports: Option<u64>,
297    /// Requested priority fee, rounded up with Solana runtime semantics.
298    pub priority_fee_lamports: Option<u64>,
299    /// `true` when status metadata confirms the transaction succeeded.
300    pub tip_payments_confirmed: bool,
301    pub tip_lamports: u64,
302    pub tip_payments: Vec<TipPayment>,
303}
304
305impl TransactionCost {
306    /// Returns the total tip paid to a specific provider.
307    #[inline]
308    pub fn tip_lamports_for(&self, provider: SwqosProvider) -> u64 {
309        self.tip_payments
310            .iter()
311            .filter(|payment| payment.provider == provider)
312            .fold(0u64, |total, payment| total.saturating_add(payment.lamports))
313    }
314}
315
316#[derive(Default)]
317struct ScanState {
318    compute_unit_limit: Option<u32>,
319    compute_unit_price_micro_lamports: Option<u64>,
320    seen_compute_budget_tags: u8,
321    invalid_compute_budget: bool,
322    tip_lamports: u64,
323    tip_payments: Vec<TipPayment>,
324}
325
326impl ScanState {
327    #[inline]
328    fn finish(
329        mut self,
330        transaction_fee_lamports: Option<u64>,
331        compute_units_consumed: Option<u64>,
332        tip_payments_confirmed: bool,
333    ) -> TransactionCost {
334        if self.invalid_compute_budget {
335            self.compute_unit_limit = None;
336            self.compute_unit_price_micro_lamports = None;
337        }
338        let priority_fee_lamports = self
339            .compute_unit_limit
340            .zip(self.compute_unit_price_micro_lamports)
341            .map(|(limit, price)| priority_fee_lamports(limit, price));
342        TransactionCost {
343            transaction_fee_lamports,
344            total_fee_and_tip_lamports: transaction_fee_lamports
345                .map(|fee| fee.saturating_add(self.tip_lamports)),
346            compute_units_consumed,
347            compute_unit_limit: self.compute_unit_limit,
348            compute_unit_price_micro_lamports: self.compute_unit_price_micro_lamports,
349            priority_fee_lamports,
350            tip_payments_confirmed,
351            tip_lamports: self.tip_lamports,
352            tip_payments: self.tip_payments,
353        }
354    }
355}
356
357/// Parses costs from a base64-encoded RPC transaction response.
358pub fn parse_rpc_transaction_cost(
359    transaction: &EncodedConfirmedTransactionWithStatusMeta,
360) -> Result<TransactionCost, ParseError> {
361    let (meta, transaction) = convert_rpc_to_grpc(transaction)?;
362    parse_yellowstone_transaction_cost(&transaction, &meta)
363        .ok_or_else(|| ParseError::MissingField("transaction.message".to_string()))
364}
365
366/// Parses costs from a Yellowstone transaction, including ALT-loaded accounts
367/// and inner System Program transfers. Returns `None` when the transaction
368/// message is absent.
369pub fn parse_yellowstone_transaction_cost(
370    transaction: &Transaction,
371    meta: &TransactionStatusMeta,
372) -> Option<TransactionCost> {
373    let message = transaction.message.as_ref()?;
374    let get_key = |index: usize| {
375        let static_len = message.account_keys.len();
376        if index < static_len {
377            return pubkey_from_bytes(message.account_keys.get(index)?);
378        }
379        let writable_index = index - static_len;
380        if writable_index < meta.loaded_writable_addresses.len() {
381            return pubkey_from_bytes(meta.loaded_writable_addresses.get(writable_index)?);
382        }
383        let readonly_index = writable_index - meta.loaded_writable_addresses.len();
384        pubkey_from_bytes(meta.loaded_readonly_addresses.get(readonly_index)?)
385    };
386
387    let mut state = ScanState::default();
388    for instruction in &message.instructions {
389        scan_instruction(
390            instruction.program_id_index as usize,
391            &instruction.accounts,
392            &instruction.data,
393            &get_key,
394            meta.err.is_none(),
395            &mut state,
396        );
397    }
398    if meta.err.is_none() {
399        for group in &meta.inner_instructions {
400            for instruction in &group.instructions {
401                scan_inner_tip(instruction, &get_key, &mut state);
402            }
403        }
404    }
405
406    Some(state.finish(Some(meta.fee), meta.compute_units_consumed, meta.err.is_none()))
407}
408
409/// Parses requested compute budget and outer relay tips from a ShredStream
410/// transaction. Transaction status metadata and ALT-loaded addresses are not
411/// present in shred entries, so fee and consumed-CU fields remain `None` and
412/// tips whose recipient is ALT-loaded cannot be recognized.
413pub fn parse_shred_transaction_cost(transaction: &VersionedTransaction) -> TransactionCost {
414    let message = &transaction.message;
415    let static_keys = message.static_account_keys();
416    let get_key = |index: usize| static_keys.get(index).copied();
417    let mut state = ScanState::default();
418    for instruction in message.instructions() {
419        scan_instruction(
420            instruction.program_id_index as usize,
421            &instruction.accounts,
422            &instruction.data,
423            &get_key,
424            true,
425            &mut state,
426        );
427    }
428    state.finish(None, None, false)
429}
430
431#[inline]
432fn scan_inner_tip<F>(instruction: &InnerInstruction, get_key: &F, state: &mut ScanState)
433where
434    F: Fn(usize) -> Option<Pubkey>,
435{
436    let Some(program_id) = get_key(instruction.program_id_index as usize) else {
437        return;
438    };
439    if program_id == SYSTEM_PROGRAM_ID {
440        scan_system_transfer(&instruction.accounts, &instruction.data, get_key, state);
441    }
442}
443
444#[inline]
445fn scan_instruction<F>(
446    program_id_index: usize,
447    accounts: &[u8],
448    data: &[u8],
449    get_key: &F,
450    scan_tips: bool,
451    state: &mut ScanState,
452) where
453    F: Fn(usize) -> Option<Pubkey>,
454{
455    let Some(program_id) = get_key(program_id_index) else {
456        return;
457    };
458    if program_id == COMPUTE_BUDGET_PROGRAM_ID {
459        scan_compute_budget(data, state);
460    } else if scan_tips && program_id == SYSTEM_PROGRAM_ID {
461        scan_system_transfer(accounts, data, get_key, state);
462    }
463}
464
465#[inline]
466fn scan_compute_budget(data: &[u8], state: &mut ScanState) {
467    let Some(tag) = data.first().copied() else {
468        state.invalid_compute_budget = true;
469        return;
470    };
471    if tag > SET_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_TAG {
472        state.invalid_compute_budget = true;
473        return;
474    }
475    let tag_mask = 1u8 << tag;
476    if state.seen_compute_budget_tags & tag_mask != 0 {
477        state.invalid_compute_budget = true;
478        return;
479    }
480    state.seen_compute_budget_tags |= tag_mask;
481
482    match tag {
483        SET_COMPUTE_UNIT_LIMIT_TAG => {
484            let Some(value) = read_u32_exact(data) else {
485                state.invalid_compute_budget = true;
486                return;
487            };
488            state.compute_unit_limit = Some(value);
489        }
490        SET_COMPUTE_UNIT_PRICE_TAG => {
491            let Some(value) = read_u64_exact(data) else {
492                state.invalid_compute_budget = true;
493                return;
494            };
495            state.compute_unit_price_micro_lamports = Some(value);
496        }
497        REQUEST_HEAP_FRAME_TAG | SET_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_TAG if data.len() == 5 => {}
498        _ => state.invalid_compute_budget = true,
499    }
500}
501
502#[inline]
503fn scan_system_transfer<F>(accounts: &[u8], data: &[u8], get_key: &F, state: &mut ScanState)
504where
505    F: Fn(usize) -> Option<Pubkey>,
506{
507    if data.len() != 12 || data.get(..4) != Some(SYSTEM_TRANSFER_TAG.as_slice()) {
508        return;
509    }
510    let Some(source) = accounts.first().and_then(|index| get_key(*index as usize)) else {
511        return;
512    };
513    let Some(recipient) = accounts.get(1).and_then(|index| get_key(*index as usize)) else {
514        return;
515    };
516    let Some(provider) = find_tip_provider(&recipient) else {
517        return;
518    };
519    let lamports = u64::from_le_bytes(data[4..12].try_into().expect("validated transfer length"));
520    state.tip_lamports = state.tip_lamports.saturating_add(lamports);
521    state.tip_payments.push(TipPayment { provider, source, recipient, lamports });
522}
523
524#[inline]
525fn find_tip_provider(recipient: &Pubkey) -> Option<SwqosProvider> {
526    SWQOS_TIP_ACCOUNT_GROUPS
527        .iter()
528        .find(|group| group.accounts.contains(recipient))
529        .map(|group| group.provider)
530}
531
532#[inline]
533fn priority_fee_lamports(compute_unit_limit: u32, micro_lamports: u64) -> u64 {
534    let fee = (compute_unit_limit as u128).saturating_mul(micro_lamports as u128);
535    fee.saturating_add(MICRO_LAMPORTS_PER_LAMPORT - 1)
536        .checked_div(MICRO_LAMPORTS_PER_LAMPORT)
537        .and_then(|value| u64::try_from(value).ok())
538        .unwrap_or(u64::MAX)
539}
540
541#[inline]
542fn read_u32_exact(data: &[u8]) -> Option<u32> {
543    (data.len() == 5).then(|| u32::from_le_bytes(data[1..5].try_into().unwrap()))
544}
545
546#[inline]
547fn read_u64_exact(data: &[u8]) -> Option<u64> {
548    (data.len() == 9).then(|| u64::from_le_bytes(data[1..9].try_into().unwrap()))
549}
550
551#[inline]
552fn pubkey_from_bytes(bytes: &[u8]) -> Option<Pubkey> {
553    Pubkey::try_from(bytes).ok()
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use solana_sdk::{
560        hash::Hash,
561        message::{
562            compiled_instruction::CompiledInstruction as SdkInstruction, v0, MessageHeader,
563            VersionedMessage,
564        },
565        signature::Signature,
566    };
567    use yellowstone_grpc_proto::prelude::{
568        CompiledInstruction, InnerInstructions, Message, TransactionError, TransactionStatusMeta,
569    };
570
571    fn compute_limit(units: u32) -> Vec<u8> {
572        let mut data = vec![SET_COMPUTE_UNIT_LIMIT_TAG];
573        data.extend_from_slice(&units.to_le_bytes());
574        data
575    }
576
577    fn compute_price(micro_lamports: u64) -> Vec<u8> {
578        let mut data = vec![SET_COMPUTE_UNIT_PRICE_TAG];
579        data.extend_from_slice(&micro_lamports.to_le_bytes());
580        data
581    }
582
583    fn transfer(lamports: u64) -> Vec<u8> {
584        let mut data = SYSTEM_TRANSFER_TAG.to_vec();
585        data.extend_from_slice(&lamports.to_le_bytes());
586        data
587    }
588
589    fn grpc_instruction(
590        program_id_index: u32,
591        accounts: Vec<u8>,
592        data: Vec<u8>,
593    ) -> CompiledInstruction {
594        CompiledInstruction { program_id_index, accounts, data }
595    }
596
597    #[test]
598    fn yellowstone_parser_reads_fee_compute_budget_and_known_tip() {
599        let source = Pubkey::new_unique();
600        let ordinary_recipient = Pubkey::new_unique();
601        let keys = [source, COMPUTE_BUDGET_PROGRAM_ID, SYSTEM_PROGRAM_ID, ordinary_recipient];
602        let transaction = Transaction {
603            signatures: vec![vec![0; 64]],
604            message: Some(Message {
605                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
606                instructions: vec![
607                    grpc_instruction(1, vec![], compute_limit(300_000)),
608                    grpc_instruction(1, vec![], compute_price(12_345)),
609                    grpc_instruction(2, vec![0, 3], transfer(99_999)),
610                    grpc_instruction(2, vec![0, 4], transfer(137_273)),
611                ],
612                ..Default::default()
613            }),
614        };
615        let meta = TransactionStatusMeta {
616            fee: 8_704,
617            compute_units_consumed: Some(135_026),
618            loaded_writable_addresses: vec![JITO_TIP_ACCOUNTS[0].to_bytes().to_vec()],
619            ..Default::default()
620        };
621
622        let cost =
623            parse_yellowstone_transaction_cost(&transaction, &meta).expect("transaction cost");
624
625        assert_eq!(cost.transaction_fee_lamports, Some(8_704));
626        assert_eq!(cost.compute_units_consumed, Some(135_026));
627        assert_eq!(cost.compute_unit_limit, Some(300_000));
628        assert_eq!(cost.compute_unit_price_micro_lamports, Some(12_345));
629        assert_eq!(cost.priority_fee_lamports, Some(3_704));
630        assert!(cost.tip_payments_confirmed);
631        assert_eq!(cost.tip_lamports, 137_273);
632        assert_eq!(cost.total_fee_and_tip_lamports, Some(145_977));
633        assert_eq!(cost.tip_payments.len(), 1);
634        assert_eq!(cost.tip_payments[0].provider, SwqosProvider::Jito);
635        assert_eq!(cost.tip_payments[0].recipient, JITO_TIP_ACCOUNTS[0]);
636        assert_eq!(cost.tip_lamports_for(SwqosProvider::Jito), 137_273);
637    }
638
639    #[test]
640    fn malformed_duplicate_and_out_of_bounds_instructions_do_not_panic_or_misreport() {
641        let keys = [COMPUTE_BUDGET_PROGRAM_ID, SYSTEM_PROGRAM_ID];
642        let transaction = Transaction {
643            signatures: vec![],
644            message: Some(Message {
645                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
646                instructions: vec![
647                    grpc_instruction(0, vec![], compute_limit(100_000)),
648                    grpc_instruction(0, vec![], compute_limit(200_000)),
649                    grpc_instruction(0, vec![], vec![SET_COMPUTE_UNIT_PRICE_TAG, 1]),
650                    grpc_instruction(1, vec![254, 253], transfer(1_000)),
651                    grpc_instruction(250, vec![], vec![]),
652                ],
653                ..Default::default()
654            }),
655        };
656
657        let cost =
658            parse_yellowstone_transaction_cost(&transaction, &TransactionStatusMeta::default())
659                .expect("transaction cost");
660
661        assert_eq!(cost.compute_unit_limit, None);
662        assert_eq!(cost.compute_unit_price_micro_lamports, None);
663        assert_eq!(cost.priority_fee_lamports, None);
664        assert_eq!(cost.tip_lamports, 0);
665    }
666
667    #[test]
668    fn yellowstone_parser_sums_outer_and_inner_tip_payments() {
669        let source = Pubkey::new_unique();
670        let keys = [source, SYSTEM_PROGRAM_ID, JITO_TIP_ACCOUNTS[0], GLAIVE_TIP_ACCOUNTS[0]];
671        let transaction = Transaction {
672            signatures: vec![],
673            message: Some(Message {
674                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
675                instructions: vec![grpc_instruction(1, vec![0, 2], transfer(10))],
676                ..Default::default()
677            }),
678        };
679        let meta = TransactionStatusMeta {
680            inner_instructions: vec![InnerInstructions {
681                index: 0,
682                instructions: vec![InnerInstruction {
683                    program_id_index: 1,
684                    accounts: vec![0, 3],
685                    data: transfer(20),
686                    stack_height: Some(2),
687                }],
688            }],
689            ..Default::default()
690        };
691
692        let cost =
693            parse_yellowstone_transaction_cost(&transaction, &meta).expect("transaction cost");
694
695        assert_eq!(cost.tip_lamports, 30);
696        assert_eq!(cost.tip_payments.len(), 2);
697        assert_eq!(cost.tip_lamports_for(SwqosProvider::Jito), 10);
698        assert_eq!(cost.tip_lamports_for(SwqosProvider::Glaive), 20);
699    }
700
701    #[test]
702    fn failed_transaction_does_not_report_rolled_back_tips() {
703        let source = Pubkey::new_unique();
704        let keys = [source, SYSTEM_PROGRAM_ID, JITO_TIP_ACCOUNTS[0]];
705        let transaction = Transaction {
706            signatures: vec![],
707            message: Some(Message {
708                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
709                instructions: vec![grpc_instruction(1, vec![0, 2], transfer(500))],
710                ..Default::default()
711            }),
712        };
713        let meta = TransactionStatusMeta {
714            err: Some(TransactionError { err: vec![1] }),
715            fee: 5_000,
716            ..Default::default()
717        };
718
719        let cost =
720            parse_yellowstone_transaction_cost(&transaction, &meta).expect("transaction cost");
721
722        assert_eq!(cost.transaction_fee_lamports, Some(5_000));
723        assert!(!cost.tip_payments_confirmed);
724        assert_eq!(cost.tip_lamports, 0);
725        assert!(cost.tip_payments.is_empty());
726    }
727
728    #[test]
729    fn inner_compute_budget_instruction_does_not_set_transaction_budget() {
730        let keys = [COMPUTE_BUDGET_PROGRAM_ID];
731        let transaction = Transaction {
732            signatures: vec![],
733            message: Some(Message {
734                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
735                ..Default::default()
736            }),
737        };
738        let meta = TransactionStatusMeta {
739            inner_instructions: vec![InnerInstructions {
740                index: 0,
741                instructions: vec![InnerInstruction {
742                    program_id_index: 0,
743                    accounts: vec![],
744                    data: compute_limit(999_999),
745                    stack_height: Some(2),
746                }],
747            }],
748            ..Default::default()
749        };
750
751        let cost =
752            parse_yellowstone_transaction_cost(&transaction, &meta).expect("transaction cost");
753
754        assert_eq!(cost.compute_unit_limit, None);
755    }
756
757    #[test]
758    fn shred_parser_exposes_requested_costs_without_status_metadata() {
759        let source = Pubkey::new_unique();
760        let keys = vec![source, COMPUTE_BUDGET_PROGRAM_ID, SYSTEM_PROGRAM_ID, JITO_TIP_ACCOUNTS[0]];
761        let transaction = VersionedTransaction {
762            signatures: vec![Signature::default()],
763            message: VersionedMessage::V0(v0::Message {
764                header: MessageHeader::default(),
765                account_keys: keys,
766                recent_blockhash: Hash::default(),
767                instructions: vec![
768                    SdkInstruction::new_from_raw_parts(1, compute_limit(200_000), vec![]),
769                    SdkInstruction::new_from_raw_parts(1, compute_price(5_001), vec![]),
770                    SdkInstruction::new_from_raw_parts(2, transfer(42), vec![0, 3]),
771                ],
772                address_table_lookups: vec![],
773            }),
774        };
775
776        let cost = parse_shred_transaction_cost(&transaction);
777
778        assert_eq!(cost.transaction_fee_lamports, None);
779        assert_eq!(cost.compute_units_consumed, None);
780        assert!(!cost.tip_payments_confirmed);
781        assert_eq!(cost.priority_fee_lamports, Some(1_001));
782        assert_eq!(cost.tip_lamports, 42);
783    }
784
785    #[test]
786    fn recognizes_every_sol_trade_sdk_provider() {
787        assert_eq!(SWQOS_TIP_ACCOUNT_GROUPS.len(), 17);
788        for group in SWQOS_TIP_ACCOUNT_GROUPS {
789            assert!(!group.accounts.is_empty());
790            assert_eq!(find_tip_provider(&group.accounts[0]), Some(group.provider));
791        }
792    }
793
794    #[test]
795    fn provider_tip_accounts_are_unique() {
796        for (group_index, group) in SWQOS_TIP_ACCOUNT_GROUPS.iter().enumerate() {
797            for (account_index, account) in group.accounts.iter().enumerate() {
798                for candidate_group in &SWQOS_TIP_ACCOUNT_GROUPS[group_index..] {
799                    let start = if candidate_group.provider == group.provider {
800                        account_index + 1
801                    } else {
802                        0
803                    };
804                    assert!(
805                        !candidate_group.accounts[start..].contains(account),
806                        "duplicate tip account {account}"
807                    );
808                }
809            }
810        }
811    }
812
813    #[test]
814    fn priority_fee_rounds_up_and_saturates() {
815        assert_eq!(priority_fee_lamports(1, 1), 1);
816        assert_eq!(priority_fee_lamports(200_000, 5_000), 1_000);
817        assert_eq!(priority_fee_lamports(u32::MAX, u64::MAX), u64::MAX);
818    }
819}