1use std::collections::{HashMap, HashSet};
2
3use base64::Engine as _;
4use base64::engine::general_purpose::STANDARD;
5use solana_sdk::{
6 instruction::{AccountMeta, Instruction},
7 message::AddressLookupTableAccount,
8 pubkey::Pubkey,
9};
10
11use super::manager::VaultManager;
12use super::price_route::PriceRoute;
13use super::trading::{
14 NO_AMOUNT_OFFSET, VaultSaleTerms, VaultStagingAsset, VaultSwapAmountOffset, VaultSwapHop,
15 VaultSwapKind, VaultSwapPlan,
16};
17use super::{VaultAccount, VaultChainError, pda};
18use crate::chain::ix::{
19 ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID,
20 derive_associated_token_address as ata,
21};
22use crate::orders::{SignerLike, verify_order_id_signature_bytes};
23use crate::types::{VaultLeg, VaultStagingStep, VaultSwapDirection, VaultSwapMode};
24use crate::ws::types::{SponsoredTxToSignData, SwapQuoteData, SwapQuoteResultData};
25
26const COMPUTE_BUDGET_PROGRAM_ID: Pubkey =
27 solana_sdk::pubkey!("ComputeBudget111111111111111111111111111111");
28const ED25519_PROGRAM_ID: Pubkey =
29 solana_sdk::pubkey!("Ed25519SigVerify111111111111111111111111111");
30const ED25519_HEADER: [u8; 16] = [
31 1, 0, 48, 0, 255, 255, 16, 0, 255, 255, 112, 0, 32, 0, 255, 255,
32];
33const ED25519_DATA_LEN: usize = 144;
34const SIGNATURE_LEN: usize = 64;
35const MAX_TRANSACTION_WIRE_BYTES: usize = 1232;
36const SET_COMPUTE_UNIT_LIMIT: u8 = 2;
37const SET_COMPUTE_UNIT_PRICE: u8 = 3;
38const MAX_COMPUTE_UNIT_LIMIT: u32 = 1_400_000;
39const BPS: u128 = 10_000;
40const STAGING_GENERATION_OFFSET: usize = 27;
41pub(super) const STAKE_POOL_PROGRAM_IDS: [Pubkey; 2] = [
43 solana_sdk::pubkey!("SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy"),
44 solana_sdk::pubkey!("SPMBzsVUuoHA4Jm6KunbsotaahvVikZs1JyTW6iJvbn"),
45];
46const UPDATE_STAKE_POOL_BALANCE: u8 = 7;
47
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49#[non_exhaustive]
50pub enum VaultIntentError {
51 #[error("transaction is not valid base64")]
52 InvalidBase64,
53 #[error("transaction bytes are malformed: {0}")]
54 Malformed(&'static str),
55 #[error("unsupported transaction message version {0}")]
56 UnsupportedVersion(u8),
57 #[error("vault opens must use a version-0 transaction")]
58 OpenRequiresVersionZero,
59 #[error("vault opens must use an address lookup table")]
60 OpenRequiresLookupTable,
61 #[error("vault-open account {0} must be a static transaction key")]
62 OpenRequiresStaticAccount(Pubkey),
63 #[error("vault-open transaction is {actual} bytes, exceeding the {maximum}-byte wire limit")]
64 OpenTransactionTooLarge { actual: usize, maximum: usize },
65 #[error("lookup table {0} was not provided")]
66 MissingLookupTable(Pubkey),
67 #[error("lookup table {table} has no index {index}")]
68 LookupIndex { table: Pubkey, index: u8 },
69 #[error("fee payer is {found}, expected {expected}")]
70 FeePayer { expected: Pubkey, found: Pubkey },
71 #[error("transaction has {found} instructions, expected {expected}")]
72 InstructionCount { expected: usize, found: usize },
73 #[error("instruction {index} calls program {found}, expected {expected}")]
74 Program {
75 index: usize,
76 expected: Pubkey,
77 found: Pubkey,
78 },
79 #[error("instruction {index} has {found} accounts, expected {expected}")]
80 AccountCount {
81 index: usize,
82 expected: usize,
83 found: usize,
84 },
85 #[error(
86 "instruction {index} account {position} is {found} (signer {found_signer}, writable {found_writable}), expected {expected} (signer {expected_signer}, writable {expected_writable})"
87 )]
88 Account {
89 index: usize,
90 position: usize,
91 expected: Pubkey,
92 expected_signer: bool,
93 expected_writable: bool,
94 found: Pubkey,
95 found_signer: bool,
96 found_writable: bool,
97 },
98 #[error("instruction {index} data differs at byte {offset}")]
99 Data { index: usize, offset: usize },
100 #[error("account {key} is writable {found_writable}, expected {expected_writable}")]
101 Privilege {
102 key: Pubkey,
103 expected_writable: bool,
104 found_writable: bool,
105 },
106 #[error("signer {0} is not one of the transaction's required signers")]
107 SignerNotRequired(Pubkey),
108 #[error("vault-open participant signer is {found}, expected {expected}")]
109 ParticipantSigner { expected: Pubkey, found: Pubkey },
110 #[error("transaction requires signer {0} outside the intent")]
111 UnexpectedSigner(Pubkey),
112 #[error("transaction loads account {0} that no expected instruction uses")]
113 UnexpectedAccount(Pubkey),
114 #[error("compute unit limit {limit} exceeds {max}")]
115 ComputeUnitLimit { limit: u32, max: u32 },
116 #[error("compute unit price {price} exceeds {max}")]
117 ComputeUnitPrice { price: u64, max: u64 },
118 #[error("compute budget instruction {0} is not a canonical limit/price instruction")]
119 ComputeBudget(usize),
120 #[error("maker quote signature instruction: {0}")]
121 QuoteSignature(&'static str),
122 #[error("served order id differs from the one the terms bind")]
123 OrderId,
124 #[error("swap route: {0}")]
125 Route(&'static str),
126 #[error("swap terms: {0}")]
127 Terms(&'static str),
128 #[error("served {0} price section is not the vault's price route on a configured shard")]
129 PriceSection(&'static str),
130 #[error("swap setup: {0}")]
131 Setup(&'static str),
132 #[error(transparent)]
133 Intent(#[from] VaultChainError),
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct ComputeBudgetBounds {
139 pub max_unit_limit: u32,
140 pub max_unit_price_micro_lamports: u64,
141}
142
143#[derive(Debug, Clone, Copy)]
145pub enum VaultOpenIntent<'a> {
146 Sale {
149 keeper: Pubkey,
151 writer: &'a VaultManager,
152 maker_owner: Pubkey,
153 maker_quote_signer: Pubkey,
154 terms: VaultSaleTerms,
155 },
156 BetweenVaults {
159 keeper: Pubkey,
161 writer: &'a VaultManager,
162 holder: &'a VaultManager,
163 terms: VaultSaleTerms,
164 },
165 Fill {
168 keeper: Pubkey,
170 holder: &'a VaultManager,
171 taker: Pubkey,
172 terms: VaultSaleTerms,
173 },
174 Buy {
178 keeper: Pubkey,
180 writer: &'a VaultManager,
181 holder: Pubkey,
182 terms: VaultSaleTerms,
183 },
184}
185
186#[derive(Debug, Clone, Copy)]
188pub struct PairPriceRoutes<'a> {
189 pub main: &'a PriceRoute,
190 pub second: &'a PriceRoute,
191 pub staging: &'a [PriceRoute],
193 pub shards: &'a [u16],
194}
195
196#[derive(Debug, Clone, Copy)]
198pub struct VaultSwapIntent<'a> {
199 pub vault: &'a VaultManager,
200 pub request: &'a SwapQuoteData,
201 pub quote: &'a SwapQuoteResultData,
202 pub price_routes: PairPriceRoutes<'a>,
203 pub min_output_floor: u64,
204 pub account: VaultAccount,
206}
207
208pub fn verify_vault_open(
210 to_sign: &SponsoredTxToSignData,
211 intent: &VaultOpenIntent<'_>,
212 tables: &[AddressLookupTableAccount],
213 bounds: ComputeBudgetBounds,
214) -> Result<VerifiedTransaction, VaultIntentError> {
215 let served = ServedMessage::decode(&to_sign.tx_base64, tables)?;
216 served.require_vault_open_wire_shape()?;
217 let (keeper, participant, quote_signer, open) = match *intent {
218 VaultOpenIntent::Sale {
219 keeper,
220 writer,
221 maker_owner,
222 maker_quote_signer,
223 terms,
224 } => (
225 keeper,
226 writer.args.actor,
227 maker_quote_signer,
228 match terms {
229 VaultSaleTerms::Physical(terms) => {
230 writer.open_position_as_writer(keeper, maker_owner, &terms)?
231 }
232 VaultSaleTerms::Spread(terms) => {
233 writer.open_spread_as_writer(keeper, maker_owner, &terms)?
234 }
235 },
236 ),
237 VaultOpenIntent::BetweenVaults {
238 keeper,
239 writer,
240 holder,
241 terms,
242 } => (
243 keeper,
244 writer.args.actor,
245 holder.args.delegate_authority,
246 match terms {
247 VaultSaleTerms::Physical(terms) => {
248 writer.open_position_with_vault_holder(keeper, holder, &terms)?
249 }
250 VaultSaleTerms::Spread(terms) => {
251 writer.open_spread_with_vault_holder(keeper, holder, &terms)?
252 }
253 },
254 ),
255 VaultOpenIntent::Fill {
256 keeper,
257 holder,
258 taker,
259 terms,
260 } => (
261 keeper,
262 taker,
263 holder.args.delegate_authority,
264 match terms {
265 VaultSaleTerms::Physical(terms) => {
266 holder.open_position_as_holder(keeper, taker, &terms)?
267 }
268 VaultSaleTerms::Spread(terms) => {
269 holder.open_spread_as_holder(keeper, taker, &terms)?
270 }
271 },
272 ),
273 VaultOpenIntent::Buy {
274 keeper,
275 writer,
276 holder,
277 terms,
278 } => (
279 keeper,
280 holder,
281 writer.args.delegate_authority,
282 match terms {
283 VaultSaleTerms::Physical(terms) => {
284 writer.open_position_for_wallet_holder(keeper, holder, &terms)?
285 }
286 VaultSaleTerms::Spread(terms) => {
287 writer.open_spread_for_wallet_holder(keeper, holder, &terms)?
288 }
289 },
290 ),
291 };
292 if open.order_id != *to_sign.order_id.as_bytes() {
293 return Err(VaultIntentError::OrderId);
294 }
295 let mut static_accounts = vec![
296 keeper,
297 participant,
298 open.addresses.position,
299 open.addresses.collateral_ata,
300 ];
301 let collateral_ata = create_ata_idempotent(
302 keeper,
303 open.addresses.position,
304 open.addresses.collateral_mint,
305 open.addresses.collateral_token_program,
306 );
307 let mut expected = served.compute_budget(bounds)?;
308 if let VaultOpenIntent::Fill {
309 holder,
310 taker,
311 terms,
312 ..
313 } = intent
314 {
315 let quote_mint = match terms {
316 VaultSaleTerms::Physical(terms) => terms.quote_mint,
317 VaultSaleTerms::Spread(terms) => terms.quote_mint,
318 };
319 let (quote_mint, quote_program) = if holder.args.main_mint == quote_mint {
320 (holder.args.main_mint, holder.args.main_token_program)
321 } else {
322 (holder.args.second_mint, holder.args.second_token_program)
323 };
324 expected.push(create_ata_idempotent(
325 keeper,
326 *taker,
327 quote_mint,
328 quote_program,
329 ));
330 }
331 expected.push(collateral_ata);
332 expected.push(served.quote_signature(expected.len(), quote_signer, &open.order_id)?);
333 expected.push(open.instruction);
334 static_accounts.extend(expected.iter().map(|instruction| instruction.program_id));
335 static_accounts.extend(
336 expected
337 .iter()
338 .flat_map(|instruction| instruction.accounts.iter())
339 .filter(|account| account.is_signer)
340 .map(|account| account.pubkey),
341 );
342 served.require_static_accounts(&static_accounts)?;
343 served.matches(keeper, &expected)
344}
345
346pub fn verify_vault_swap(
348 intent: &VaultSwapIntent<'_>,
349 tables: &[AddressLookupTableAccount],
350 bounds: ComputeBudgetBounds,
351) -> Result<VerifiedTransaction, VaultIntentError> {
352 let VaultSwapIntent {
353 vault,
354 request,
355 quote,
356 min_output_floor,
357 ..
358 } = *intent;
359 vault.ensure_current(&intent.account)?;
360 let slot = intent.account.staging;
361 if request.vault_pda != vault.addresses.vault.into() {
362 return Err(VaultIntentError::Terms("request names a different vault"));
363 }
364 if quote.request_id != request.request_id || quote.mode != request.mode {
365 return Err(VaultIntentError::Terms(
366 "quote answers a different request or mode",
367 ));
368 }
369 let kind = match (request.mode, request.direction) {
370 (VaultSwapMode::Rebalance, VaultSwapDirection::MainToSecond) => VaultSwapKind::Rebalance {
371 second_to_main: false,
372 },
373 (VaultSwapMode::Rebalance, VaultSwapDirection::SecondToMain) => VaultSwapKind::Rebalance {
374 second_to_main: true,
375 },
376 (VaultSwapMode::Recovery, VaultSwapDirection::SecondToMain) => VaultSwapKind::Recovery,
377 (VaultSwapMode::Closing, VaultSwapDirection::SecondToMain) => VaultSwapKind::Closing,
378 _ => {
379 return Err(VaultIntentError::Terms(
380 "unsupported swap mode or direction",
381 ));
382 }
383 };
384 let amount_in = parse_amount("e.in_amount, "quoted input")?;
385 let open_slot = slot.open;
386 match (quote.staging, open_slot) {
387 (Some(VaultStagingStep::Unstage), Some(slot)) if amount_in != slot.amount => {
388 return Err(VaultIntentError::Terms(
389 "an unstage must spend the whole staged amount",
390 ));
391 }
392 (Some(VaultStagingStep::Unstage), None) => {
393 return Err(VaultIntentError::Terms("nothing is staged to unstage"));
394 }
395 (Some(VaultStagingStep::Stage), Some(_)) => {
396 return Err(VaultIntentError::Terms(
397 "a stage needs an empty staging slot",
398 ));
399 }
400 (Some(VaultStagingStep::Unstage), Some(_)) => {}
401 _ if amount_in != parse_amount(&request.amount, "request amount")? => {
402 return Err(VaultIntentError::Terms("quoted input differs from request"));
403 }
404 _ => {}
405 }
406 let quoted_out = parse_amount("e.quoted_out_amount, "quoted output")?;
407 let minimum_out = parse_amount("e.minimum_out_amount, "minimum output")?;
408 let slippage = u128::from(request.slippage_bps);
409 if slippage > BPS
410 || quoted_out == 0
411 || minimum_out == 0
412 || u128::from(minimum_out) < u128::from(quoted_out) * (BPS - slippage) / BPS
413 {
414 return Err(VaultIntentError::Terms(
415 "minimum output exceeds the requested slippage",
416 ));
417 }
418 if quote.staging != Some(VaultStagingStep::Stage) && minimum_out < min_output_floor {
420 return Err(VaultIntentError::Terms(
421 "minimum output is below the operator's floor",
422 ));
423 }
424 let valid_until = parse_amount("e.valid_until, "valid_until")?;
425
426 let served = ServedMessage::decode("e.tx_base64, tables)?;
427 let payer = vault.args.actor;
428 let mut expected = served.compute_budget(bounds)?;
429 while let Some(setup) = served
430 .instructions
431 .get(expected.len())
432 .filter(|ix| ix.program_id != vault.args.program_id)
433 {
434 expected.push(swap_setup(vault, intent.price_routes, payer, setup)?);
435 }
436 let index = expected.len();
437 let swap = served
438 .instructions
439 .get(index)
440 .ok_or(VaultIntentError::InstructionCount {
441 expected: index + 1,
442 found: served.instructions.len(),
443 })?;
444 let routes = intent.price_routes;
445 if routes.main.mint != vault.args.main_mint || routes.second.mint != vault.args.second_mint {
446 return Err(VaultIntentError::Intent(VaultChainError::InvalidInput(
447 "price routes must price the vault's main and second mints",
448 )));
449 }
450 let a = &vault.addresses;
451 let mut priced_legs = [
452 ("main", a.main_price_route, routes.main),
453 ("second", a.second_price_route, routes.second),
454 ];
455 let mut kind = match quote.staging {
456 None => kind,
457 Some(step) => {
458 let (Some(mint), Some(token_program)) = (swap.accounts.get(9), swap.accounts.get(10))
459 else {
460 return Err(VaultIntentError::Route(
461 "swap instruction is shorter than its fixed layout",
462 ));
463 };
464 let route = routes
465 .staging
466 .iter()
467 .find(|route| route.mint == mint.pubkey)
468 .ok_or(VaultIntentError::PriceSection("staging"))?;
469 let expected = match (step, open_slot) {
470 (VaultStagingStep::Unstage, Some(slot)) if slot.mint != mint.pubkey => {
471 return Err(VaultIntentError::Terms(
472 "unstage names another staging mint",
473 ));
474 }
475 (VaultStagingStep::Unstage, _) => Some(slot.generation),
476 _ => slot.generation.checked_add(1),
477 };
478 let served = swap
479 .data
480 .get(STAGING_GENERATION_OFFSET..STAGING_GENERATION_OFFSET + 8)
481 .and_then(|bytes| <[u8; 8]>::try_from(bytes).ok())
482 .map(u64::from_le_bytes);
483 let generation = match (expected, served) {
484 (Some(expected), Some(served)) if expected == served => expected,
485 _ => {
486 return Err(VaultIntentError::Terms(
487 "staging generation does not name the vault's slot",
488 ));
489 }
490 };
491 let leg = match (step, request.direction) {
493 (VaultStagingStep::Stage, VaultSwapDirection::MainToSecond)
494 | (VaultStagingStep::Unstage, VaultSwapDirection::SecondToMain) => VaultLeg::Main,
495 _ => VaultLeg::Second,
496 };
497 priced_legs = [
498 priced_legs[usize::from(leg == VaultLeg::Second)],
499 (
500 "staging",
501 pda::price_route(&vault.args.program_id, &route.mint).0,
502 route,
503 ),
504 ];
505 VaultSwapKind::Staging {
506 step,
507 leg,
508 asset: VaultStagingAsset {
509 mint: mint.pubkey,
510 token_program: token_program.pubkey,
511 },
512 generation,
513 }
514 }
515 };
516 let (fixed, header) = (kind.fixed_accounts(), kind.header_len());
517 let (Some(priced), Some(hop_data)) = (swap.accounts.get(fixed..), swap.data.get(header..))
518 else {
519 return Err(VaultIntentError::Route(
520 "swap instruction is shorter than its fixed layout",
521 ));
522 };
523 let price_section = served_price_section(priced_legs, routes.shards, priced)?;
524 let mut hop_accounts = &priced[price_section.len()..];
525 let mut recovery_tail_len = 0;
526 if request.mode == VaultSwapMode::Recovery
527 && let VaultSwapKind::Staging {
528 step: VaultStagingStep::Stage,
529 asset,
530 generation,
531 ..
532 } = kind
533 {
534 let tail = hop_accounts
535 .get(2..)
536 .ok_or(VaultIntentError::Route("recovery tail is truncated"))?;
537 let main_price_section = served_price_section(
538 [("main", a.main_price_route, routes.main)],
539 routes.shards,
540 tail,
541 )?;
542 recovery_tail_len = 2 + main_price_section.len();
543 hop_accounts = &hop_accounts[recovery_tail_len..];
544 kind = VaultSwapKind::RecoveryStage {
545 asset,
546 generation,
547 main_price_section,
548 };
549 }
550 if hop_accounts.iter().any(|meta| meta.pubkey == payer) {
551 return Err(VaultIntentError::Route("route uses the fee payer"));
552 }
553 let share_mint = vault.args.share_mint;
554 let share_ata = ata(&vault.addresses.vault, &share_mint, &TOKEN_PROGRAM_ID);
555 if hop_accounts
556 .iter()
557 .any(|meta| meta.pubkey == share_mint || meta.pubkey == share_ata)
558 {
559 return Err(VaultIntentError::Route(
560 "route touches the vault share mint or its share account",
561 ));
562 }
563 let plan = VaultSwapPlan {
564 price_section,
565 amount_in,
566 minimum_out,
567 valid_until,
568 hops: served_hops(swap.data[header - 1], hop_data, hop_accounts)?,
569 };
570 let instruction = vault
571 .swap(kind.clone(), &plan)
572 .map_err(|error| match error {
573 VaultChainError::InvalidInput(reason) => VaultIntentError::Route(reason),
574 other => VaultIntentError::Intent(other),
575 })?;
576 let staging = match kind {
578 VaultSwapKind::Staging { asset, .. } | VaultSwapKind::RecoveryStage { asset, .. } => {
579 Some(asset.mint)
580 }
581 _ => None,
582 };
583 let mint = |key: &Pubkey| {
584 *key == vault.args.main_mint || *key == vault.args.second_mint || Some(*key) == staging
585 };
586 let mut pinned: Vec<&AccountMeta> = instruction.accounts[..fixed]
587 .iter()
588 .filter(|meta| !mint(&meta.pubkey))
589 .collect();
590 let mut position = fixed + plan.price_section.len();
591 pinned.extend(&instruction.accounts[position..position + recovery_tail_len]);
592 position += recovery_tail_len;
593 for hop in &plan.hops {
594 pinned.extend(&instruction.accounts[position..position + 3]);
595 position += 3 + hop.accounts.len();
596 }
597 if hop_accounts.iter().any(|meta| {
598 meta.is_writable
599 && pinned
600 .iter()
601 .any(|fixed| fixed.pubkey == meta.pubkey && !fixed.is_writable)
602 }) {
603 return Err(VaultIntentError::Route(
604 "route makes a read-only account writable",
605 ));
606 }
607 require_route_atas(&expected, &plan.hops)?;
608 expected.push(instruction);
609 served.matches(payer, &expected)
610}
611
612fn swap_setup(
614 vault: &VaultManager,
615 routes: PairPriceRoutes<'_>,
616 payer: Pubkey,
617 served: &Instruction,
618) -> Result<Instruction, VaultIntentError> {
619 let key = |position: usize| {
620 served
621 .accounts
622 .get(position)
623 .map(|meta| meta.pubkey)
624 .ok_or(VaultIntentError::Setup("instruction is truncated"))
625 };
626 let token_program = |position: usize| {
627 key(position).and_then(|program| {
628 if program == TOKEN_PROGRAM_ID || program == TOKEN_2022_PROGRAM_ID {
629 Ok(program)
630 } else {
631 Err(VaultIntentError::Setup("token program is not SPL Token"))
632 }
633 })
634 };
635 if served.program_id == ASSOCIATED_TOKEN_PROGRAM_ID {
636 let mint = key(3)?;
637 let args = &vault.args;
638 if [args.main_mint, args.second_mint, args.share_mint].contains(&mint) {
639 return Err(VaultIntentError::Setup(
640 "creates an account for a vault leg or share mint",
641 ));
642 }
643 return Ok(create_ata_idempotent(
644 payer,
645 vault.addresses.vault,
646 mint,
647 token_program(5)?,
648 ));
649 }
650 if !STAKE_POOL_PROGRAM_IDS.contains(&served.program_id) {
651 return Err(VaultIntentError::Setup(
652 "unexpected instruction before the swap",
653 ));
654 }
655 let pool = key(0)?;
656 let pinned = [routes.main, routes.second]
657 .into_iter()
658 .chain(routes.staging)
659 .flat_map(PriceRoute::account_ratios)
660 .any(|ratio| ratio.account == pool);
661 if !pinned {
662 return Err(VaultIntentError::Setup(
663 "updates a stake pool the vault's routes do not pin",
664 ));
665 }
666 Ok(update_stake_pool_balance(
667 served.program_id,
668 [pool, key(2)?, key(3)?, key(4)?, key(5)?, token_program(6)?],
669 ))
670}
671
672pub(super) fn update_stake_pool_balance(program: Pubkey, keys: [Pubkey; 6]) -> Instruction {
673 let [pool, validators, reserve, fee, mint, token_program] = keys;
674 let authority = Pubkey::find_program_address(&[pool.as_ref(), b"withdraw"], &program).0;
675 Instruction {
676 program_id: program,
677 accounts: vec![
678 AccountMeta::new(pool, false),
679 AccountMeta::new_readonly(authority, false),
680 AccountMeta::new(validators, false),
681 AccountMeta::new_readonly(reserve, false),
682 AccountMeta::new(fee, false),
683 AccountMeta::new(mint, false),
684 AccountMeta::new_readonly(token_program, false),
685 ],
686 data: vec![UPDATE_STAKE_POOL_BALANCE],
687 }
688}
689
690pub fn served_lookup_table_keys(tx_base64: &str) -> Result<Vec<Pubkey>, VaultIntentError> {
692 let bytes = STANDARD
693 .decode(tx_base64)
694 .map_err(|_| VaultIntentError::InvalidBase64)?;
695 Ok(CompiledMessage::decode(&bytes)?
696 .lookups
697 .into_iter()
698 .map(|lookup| lookup.table)
699 .collect())
700}
701
702fn served_price_section<const N: usize>(
704 legs: [(&'static str, Pubkey, &PriceRoute); N],
705 shards: &[u16],
706 served: &[AccountMeta],
707) -> Result<Vec<AccountMeta>, VaultIntentError> {
708 let mut section = Vec::new();
709 for (leg, address, route) in legs {
710 let rest = &served[section.len()..];
711 let found = shards
712 .iter()
713 .map(|shard| route.accounts(address, *shard))
714 .find(|expected| {
715 rest.get(..expected.len()).is_some_and(|served| {
716 served
717 .iter()
718 .zip(expected)
719 .all(|(served, expected)| served.pubkey == expected.pubkey)
720 })
721 })
722 .ok_or(VaultIntentError::PriceSection(leg))?;
723 section.extend(found);
724 }
725 Ok(section)
726}
727
728fn require_route_atas(
730 setup: &[Instruction],
731 hops: &[VaultSwapHop],
732) -> Result<(), VaultIntentError> {
733 let transit = hops.split_last().map_or(&[][..], |(_, transit)| transit);
734 let mut created = Vec::new();
735 for ix in setup
736 .iter()
737 .filter(|ix| ix.program_id == ASSOCIATED_TOKEN_PROGRAM_ID)
738 {
739 let ata = ix.accounts[1].pubkey;
740 let routed = transit.iter().any(|hop| hop.destination == ata)
741 || hops
742 .iter()
743 .any(|hop| hop.accounts.iter().any(|meta| meta.pubkey == ata));
744 if !routed {
745 return Err(VaultIntentError::Setup(
746 "creates an account the route does not use",
747 ));
748 }
749 if created.contains(&ata) {
750 return Err(VaultIntentError::Setup("creates the same account twice"));
751 }
752 created.push(ata);
753 }
754 Ok(())
755}
756
757fn served_hops(
758 count: u8,
759 mut data: &[u8],
760 mut accounts: &[AccountMeta],
761) -> Result<Vec<VaultSwapHop>, VaultIntentError> {
762 let truncated = VaultIntentError::Route("swap hops are truncated");
763 let mut hops = Vec::with_capacity(usize::from(count));
764 for _ in 0..count {
765 let [account_count, lo, hi, offset_lo, offset_hi, rest @ ..] = data else {
766 return Err(truncated);
767 };
768 let (amount_offset, rest) = match u16::from_le_bytes([*offset_lo, *offset_hi]) {
769 NO_AMOUNT_OFFSET => (None, rest),
770 offset => {
771 let Some((num, rest)) = rest.split_first_chunk::<8>() else {
772 return Err(truncated);
773 };
774 let Some((den, rest)) = rest.split_first_chunk::<8>() else {
775 return Err(truncated);
776 };
777 let amount = VaultSwapAmountOffset {
778 offset,
779 num: u64::from_le_bytes(*num),
780 den: u64::from_le_bytes(*den),
781 };
782 (Some(amount), rest)
783 }
784 };
785 let Some((hop_data, rest)) =
786 rest.split_at_checked(usize::from(u16::from_le_bytes([*lo, *hi])))
787 else {
788 return Err(truncated);
789 };
790 let [program, source, destination, tail @ ..] = accounts else {
791 return Err(truncated);
792 };
793 let Some((cpi, tail)) = tail.split_at_checked(usize::from(*account_count)) else {
794 return Err(truncated);
795 };
796 hops.push(VaultSwapHop {
797 program: program.pubkey,
798 source: source.pubkey,
799 destination: destination.pubkey,
800 accounts: cpi.to_vec(),
801 data: hop_data.to_vec(),
802 amount_offset,
803 });
804 data = rest;
805 accounts = tail;
806 }
807 Ok(hops)
808}
809
810fn parse_amount(value: &str, field: &'static str) -> Result<u64, VaultIntentError> {
811 value
812 .trim()
813 .parse::<u64>()
814 .map_err(|_| VaultIntentError::Terms(field))
815}
816
817pub(super) fn create_ata_idempotent(
818 payer: Pubkey,
819 owner: Pubkey,
820 mint: Pubkey,
821 token_program: Pubkey,
822) -> Instruction {
823 Instruction {
824 program_id: ASSOCIATED_TOKEN_PROGRAM_ID,
825 accounts: vec![
826 AccountMeta::new(payer, true),
827 AccountMeta::new(
828 crate::chain::ix::derive_associated_token_address(&owner, &mint, &token_program),
829 false,
830 ),
831 AccountMeta::new_readonly(owner, false),
832 AccountMeta::new_readonly(mint, false),
833 AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false),
834 AccountMeta::new_readonly(token_program, false),
835 ],
836 data: vec![1],
837 }
838}
839
840struct Lookup {
841 table: Pubkey,
842 writable: Vec<u8>,
843 readonly: Vec<u8>,
844}
845
846struct CompiledInstruction {
847 program: u8,
848 accounts: Vec<u8>,
849 data: Vec<u8>,
850}
851
852struct CompiledMessage {
853 signatures_start: usize,
854 message_start: usize,
855 required_signers: usize,
856 readonly_signed: usize,
857 readonly_unsigned: usize,
858 static_keys: Vec<Pubkey>,
859 instructions: Vec<CompiledInstruction>,
860 lookups: Vec<Lookup>,
861 is_version_zero: bool,
862}
863
864struct Reader<'a> {
865 bytes: &'a [u8],
866 pos: usize,
867}
868
869impl<'a> Reader<'a> {
870 fn take(&mut self, len: usize) -> Result<&'a [u8], VaultIntentError> {
871 let end = self
872 .pos
873 .checked_add(len)
874 .filter(|end| *end <= self.bytes.len())
875 .ok_or(VaultIntentError::Malformed("truncated transaction"))?;
876 let slice = &self.bytes[self.pos..end];
877 self.pos = end;
878 Ok(slice)
879 }
880
881 fn byte(&mut self) -> Result<u8, VaultIntentError> {
882 Ok(self.take(1)?[0])
883 }
884
885 fn compact(&mut self) -> Result<usize, VaultIntentError> {
886 let mut value = 0usize;
887 for shift in [0u32, 7, 14] {
888 let byte = self.byte()?;
889 value |= usize::from(byte & 0x7f) << shift;
890 if byte & 0x80 == 0 {
891 return Ok(value);
892 }
893 }
894 Err(VaultIntentError::Malformed("length prefix too long"))
895 }
896
897 fn key(&mut self) -> Result<Pubkey, VaultIntentError> {
898 let mut bytes = [0; 32];
899 bytes.copy_from_slice(self.take(32)?);
900 Ok(Pubkey::new_from_array(bytes))
901 }
902
903 fn vec(&mut self) -> Result<Vec<u8>, VaultIntentError> {
904 let len = self.compact()?;
905 Ok(self.take(len)?.to_vec())
906 }
907}
908
909impl CompiledMessage {
910 fn decode(transaction: &[u8]) -> Result<Self, VaultIntentError> {
911 let mut reader = Reader {
912 bytes: transaction,
913 pos: 0,
914 };
915 let signatures = reader.compact()?;
916 let signatures_start = reader.pos;
917 reader.take(signatures.saturating_mul(SIGNATURE_LEN))?;
918 let message_start = reader.pos;
919 let mut versioned = false;
920 if reader
921 .bytes
922 .get(reader.pos)
923 .is_some_and(|byte| byte & 0x80 != 0)
924 {
925 let version = reader.byte()? & 0x7f;
926 if version != 0 {
927 return Err(VaultIntentError::UnsupportedVersion(version));
928 }
929 versioned = true;
930 }
931 let required_signers = usize::from(reader.byte()?);
932 let readonly_signed = usize::from(reader.byte()?);
933 let readonly_unsigned = usize::from(reader.byte()?);
934 let key_count = reader.compact()?;
935 let static_keys = (0..key_count)
936 .map(|_| reader.key())
937 .collect::<Result<Vec<_>, _>>()?;
938 if signatures != required_signers
939 || readonly_signed > required_signers
940 || required_signers == readonly_signed
941 || required_signers + readonly_unsigned > static_keys.len()
942 {
943 return Err(VaultIntentError::Malformed("invalid message header"));
944 }
945 reader.take(32)?;
946 let instruction_count = reader.compact()?;
947 let instructions = (0..instruction_count)
948 .map(|_| {
949 Ok(CompiledInstruction {
950 program: reader.byte()?,
951 accounts: reader.vec()?,
952 data: reader.vec()?,
953 })
954 })
955 .collect::<Result<Vec<_>, VaultIntentError>>()?;
956 let mut lookups = Vec::new();
957 if versioned {
958 for _ in 0..reader.compact()? {
959 lookups.push(Lookup {
960 table: reader.key()?,
961 writable: reader.vec()?,
962 readonly: reader.vec()?,
963 });
964 }
965 }
966 if reader.pos != transaction.len() {
967 return Err(VaultIntentError::Malformed("trailing bytes"));
968 }
969 Ok(Self {
970 signatures_start,
971 message_start,
972 required_signers,
973 readonly_signed,
974 readonly_unsigned,
975 static_keys,
976 instructions,
977 lookups,
978 is_version_zero: versioned,
979 })
980 }
981}
982
983#[derive(Clone, Copy, PartialEq, Eq)]
984struct Flags {
985 signer: bool,
986 writable: bool,
987}
988
989struct ServedMessage {
990 keys: Vec<(Pubkey, Flags)>,
991 instructions: Vec<Instruction>,
992 transaction: VerifiedTransaction,
993 static_keys: HashSet<Pubkey>,
994 is_version_zero: bool,
995 has_lookup_table: bool,
996 wire_len: usize,
997}
998
999#[derive(Debug)]
1000pub struct VerifiedTransaction {
1001 bytes: Vec<u8>,
1002 signatures_start: usize,
1003 message_start: usize,
1004 signers: Vec<Pubkey>,
1005}
1006
1007impl VerifiedTransaction {
1008 pub fn sign(self, signer: &dyn SignerLike) -> Result<String, VaultIntentError> {
1009 self.sign_with_participant_signature(signer)
1010 .map(|(transaction, _)| transaction)
1011 }
1012
1013 pub(crate) fn sign_with_participant_signature(
1014 mut self,
1015 signer: &dyn SignerLike,
1016 ) -> Result<(String, String), VaultIntentError> {
1017 let key = Pubkey::new_from_array(signer.pubkey_bytes());
1018 let index = self
1019 .signers
1020 .iter()
1021 .position(|required| *required == key)
1022 .ok_or(VaultIntentError::SignerNotRequired(key))?;
1023 let signature = signer.sign_message(&self.bytes[self.message_start..]);
1024 let slot = self.signatures_start + index * SIGNATURE_LEN;
1025 self.bytes[slot..slot + SIGNATURE_LEN].copy_from_slice(&signature);
1026 Ok((
1027 STANDARD.encode(self.bytes),
1028 crate::wire::encode_base58(&signature),
1029 ))
1030 }
1031}
1032
1033impl ServedMessage {
1034 fn decode(
1035 tx_base64: &str,
1036 tables: &[AddressLookupTableAccount],
1037 ) -> Result<Self, VaultIntentError> {
1038 let bytes = STANDARD
1039 .decode(tx_base64)
1040 .map_err(|_| VaultIntentError::InvalidBase64)?;
1041 let wire_len = bytes.len();
1042 let message = CompiledMessage::decode(&bytes)?;
1043 let static_len = message.static_keys.len();
1044 let mut keys: Vec<(Pubkey, Flags)> = message
1045 .static_keys
1046 .iter()
1047 .enumerate()
1048 .map(|(index, key)| {
1049 let flags = if index < message.required_signers {
1050 Flags {
1051 signer: true,
1052 writable: index < message.required_signers - message.readonly_signed,
1053 }
1054 } else {
1055 Flags {
1056 signer: false,
1057 writable: index < static_len - message.readonly_unsigned,
1058 }
1059 };
1060 (*key, flags)
1061 })
1062 .collect();
1063 for writable in [true, false] {
1064 for lookup in &message.lookups {
1065 let table = tables
1066 .iter()
1067 .find(|table| table.key == lookup.table)
1068 .ok_or(VaultIntentError::MissingLookupTable(lookup.table))?;
1069 let indexes = if writable {
1070 &lookup.writable
1071 } else {
1072 &lookup.readonly
1073 };
1074 for index in indexes {
1075 let key = table.addresses.get(usize::from(*index)).ok_or(
1076 VaultIntentError::LookupIndex {
1077 table: lookup.table,
1078 index: *index,
1079 },
1080 )?;
1081 keys.push((
1082 *key,
1083 Flags {
1084 signer: false,
1085 writable,
1086 },
1087 ));
1088 }
1089 }
1090 }
1091 let mut seen = HashSet::with_capacity(keys.len());
1092 if !keys.iter().all(|(key, _)| seen.insert(*key)) {
1093 return Err(VaultIntentError::Malformed("duplicate account key"));
1094 }
1095 let instructions = message
1096 .instructions
1097 .iter()
1098 .map(|compiled| {
1099 let key = |index: u8| {
1100 keys.get(usize::from(index))
1101 .ok_or(VaultIntentError::Malformed("account index out of range"))
1102 };
1103 let (program_id, _) = key(compiled.program)?;
1104 let accounts = compiled
1105 .accounts
1106 .iter()
1107 .map(|index| {
1108 key(*index).map(|(pubkey, flags)| AccountMeta {
1109 pubkey: *pubkey,
1110 is_signer: flags.signer,
1111 is_writable: flags.writable,
1112 })
1113 })
1114 .collect::<Result<Vec<_>, _>>()?;
1115 Ok(Instruction {
1116 program_id: *program_id,
1117 accounts,
1118 data: compiled.data.clone(),
1119 })
1120 })
1121 .collect::<Result<Vec<_>, VaultIntentError>>()?;
1122 let transaction = VerifiedTransaction {
1123 signatures_start: message.signatures_start,
1124 message_start: message.message_start,
1125 signers: message.static_keys[..message.required_signers].to_vec(),
1126 bytes,
1127 };
1128 Ok(Self {
1129 keys,
1130 instructions,
1131 transaction,
1132 static_keys: message.static_keys.into_iter().collect(),
1133 is_version_zero: message.is_version_zero,
1134 has_lookup_table: message
1135 .lookups
1136 .iter()
1137 .any(|lookup| !lookup.writable.is_empty() || !lookup.readonly.is_empty()),
1138 wire_len,
1139 })
1140 }
1141
1142 fn require_vault_open_wire_shape(&self) -> Result<(), VaultIntentError> {
1143 if !self.is_version_zero {
1144 return Err(VaultIntentError::OpenRequiresVersionZero);
1145 }
1146 if !self.has_lookup_table {
1147 return Err(VaultIntentError::OpenRequiresLookupTable);
1148 }
1149 if self.wire_len > MAX_TRANSACTION_WIRE_BYTES {
1150 return Err(VaultIntentError::OpenTransactionTooLarge {
1151 actual: self.wire_len,
1152 maximum: MAX_TRANSACTION_WIRE_BYTES,
1153 });
1154 }
1155 Ok(())
1156 }
1157
1158 fn require_static_accounts(&self, accounts: &[Pubkey]) -> Result<(), VaultIntentError> {
1159 accounts
1160 .iter()
1161 .find(|account| !self.static_keys.contains(account))
1162 .copied()
1163 .map_or(Ok(()), |account| {
1164 Err(VaultIntentError::OpenRequiresStaticAccount(account))
1165 })
1166 }
1167
1168 fn compute_budget(
1169 &self,
1170 bounds: ComputeBudgetBounds,
1171 ) -> Result<Vec<Instruction>, VaultIntentError> {
1172 let budget = |index: usize, tag: u8, len: usize| {
1173 self.instructions
1174 .get(index)
1175 .filter(|ix| {
1176 ix.program_id == COMPUTE_BUDGET_PROGRAM_ID
1177 && ix.accounts.is_empty()
1178 && ix.data.len() == len
1179 && ix.data.first() == Some(&tag)
1180 })
1181 .map(|ix| &ix.data[1..])
1182 .ok_or(VaultIntentError::ComputeBudget(index))
1183 };
1184 let mut limit = [0; 4];
1185 limit.copy_from_slice(budget(0, SET_COMPUTE_UNIT_LIMIT, 5)?);
1186 let limit = u32::from_le_bytes(limit);
1187 let mut price = [0; 8];
1188 price.copy_from_slice(budget(1, SET_COMPUTE_UNIT_PRICE, 9)?);
1189 let price = u64::from_le_bytes(price);
1190 let max_limit = bounds.max_unit_limit.min(MAX_COMPUTE_UNIT_LIMIT);
1191 if limit == 0 || limit > max_limit {
1192 return Err(VaultIntentError::ComputeUnitLimit {
1193 limit,
1194 max: max_limit,
1195 });
1196 }
1197 if price > bounds.max_unit_price_micro_lamports {
1198 return Err(VaultIntentError::ComputeUnitPrice {
1199 price,
1200 max: bounds.max_unit_price_micro_lamports,
1201 });
1202 }
1203 let instruction = |tag: u8, value: &[u8]| {
1204 let mut data = Vec::with_capacity(1 + value.len());
1205 data.push(tag);
1206 data.extend_from_slice(value);
1207 Instruction {
1208 program_id: COMPUTE_BUDGET_PROGRAM_ID,
1209 accounts: Vec::new(),
1210 data,
1211 }
1212 };
1213 Ok(vec![
1214 instruction(SET_COMPUTE_UNIT_LIMIT, &limit.to_le_bytes()),
1215 instruction(SET_COMPUTE_UNIT_PRICE, &price.to_le_bytes()),
1216 ])
1217 }
1218
1219 fn quote_signature(
1220 &self,
1221 index: usize,
1222 signer: Pubkey,
1223 order_id: &[u8; 32],
1224 ) -> Result<Instruction, VaultIntentError> {
1225 let ix = self
1226 .instructions
1227 .get(index)
1228 .ok_or(VaultIntentError::QuoteSignature("missing"))?;
1229 if ix.program_id != ED25519_PROGRAM_ID
1230 || !ix.accounts.is_empty()
1231 || ix.data.len() != ED25519_DATA_LEN
1232 || ix.data[..16] != ED25519_HEADER
1233 {
1234 return Err(VaultIntentError::QuoteSignature(
1235 "not a single inline Ed25519 check",
1236 ));
1237 }
1238 if ix.data[16..48] != signer.to_bytes() {
1239 return Err(VaultIntentError::QuoteSignature(
1240 "binds a different quote signer",
1241 ));
1242 }
1243 if ix.data[112..] != *order_id {
1244 return Err(VaultIntentError::QuoteSignature("binds a different order"));
1245 }
1246 let mut signature = [0; SIGNATURE_LEN];
1247 signature.copy_from_slice(&ix.data[48..112]);
1248 verify_order_id_signature_bytes(order_id, &signature, &signer.to_bytes())
1249 .map_err(|_| VaultIntentError::QuoteSignature("signature does not verify"))?;
1250 Ok(ix.clone())
1251 }
1252
1253 fn matches(
1254 self,
1255 payer: Pubkey,
1256 expected: &[Instruction],
1257 ) -> Result<VerifiedTransaction, VaultIntentError> {
1258 let mut flags: HashMap<Pubkey, Flags> = HashMap::new();
1259 flags.insert(
1260 payer,
1261 Flags {
1262 signer: true,
1263 writable: true,
1264 },
1265 );
1266 for ix in expected {
1267 flags.entry(ix.program_id).or_insert(Flags {
1268 signer: false,
1269 writable: false,
1270 });
1271 for meta in &ix.accounts {
1272 let entry = flags.entry(meta.pubkey).or_insert(Flags {
1273 signer: false,
1274 writable: false,
1275 });
1276 entry.signer |= meta.is_signer;
1277 entry.writable |= meta.is_writable;
1278 }
1279 }
1280 if let Some((found, _)) = self.keys.first().filter(|(key, _)| *key != payer) {
1281 return Err(VaultIntentError::FeePayer {
1282 expected: payer,
1283 found: *found,
1284 });
1285 }
1286 if self.instructions.len() != expected.len() {
1287 return Err(VaultIntentError::InstructionCount {
1288 expected: expected.len(),
1289 found: self.instructions.len(),
1290 });
1291 }
1292 for (index, (served, expected)) in self.instructions.iter().zip(expected).enumerate() {
1293 if served.program_id != expected.program_id {
1294 return Err(VaultIntentError::Program {
1295 index,
1296 expected: expected.program_id,
1297 found: served.program_id,
1298 });
1299 }
1300 if served.accounts.len() != expected.accounts.len() {
1301 return Err(VaultIntentError::AccountCount {
1302 index,
1303 expected: expected.accounts.len(),
1304 found: served.accounts.len(),
1305 });
1306 }
1307 for (position, (found, wanted)) in
1308 served.accounts.iter().zip(&expected.accounts).enumerate()
1309 {
1310 let wanted_flags = match flags.get(&wanted.pubkey) {
1311 Some(flags) => *flags,
1312 None => Flags {
1313 signer: wanted.is_signer,
1314 writable: wanted.is_writable,
1315 },
1316 };
1317 if found.pubkey != wanted.pubkey
1318 || found.is_signer != wanted_flags.signer
1319 || found.is_writable != wanted_flags.writable
1320 {
1321 return Err(VaultIntentError::Account {
1322 index,
1323 position,
1324 expected: wanted.pubkey,
1325 expected_signer: wanted_flags.signer,
1326 expected_writable: wanted_flags.writable,
1327 found: found.pubkey,
1328 found_signer: found.is_signer,
1329 found_writable: found.is_writable,
1330 });
1331 }
1332 }
1333 if served.data != expected.data {
1334 let offset = match served
1335 .data
1336 .iter()
1337 .zip(&expected.data)
1338 .position(|(a, b)| a != b)
1339 {
1340 Some(offset) => offset,
1341 None => served.data.len().min(expected.data.len()),
1342 };
1343 return Err(VaultIntentError::Data { index, offset });
1344 }
1345 }
1346 for (key, found) in &self.keys {
1347 match flags.get(key) {
1348 None => return Err(VaultIntentError::UnexpectedAccount(*key)),
1349 Some(wanted) if found.signer && !wanted.signer => {
1350 return Err(VaultIntentError::UnexpectedSigner(*key));
1351 }
1352 Some(wanted) if found != wanted => {
1353 return Err(VaultIntentError::Privilege {
1354 key: *key,
1355 expected_writable: wanted.writable,
1356 found_writable: found.writable,
1357 });
1358 }
1359 Some(_) => {}
1360 }
1361 }
1362 Ok(self.transaction)
1363 }
1364}
1365
1366#[cfg(test)]
1367#[path = "served_tests.rs"]
1368mod tests;