Skip to main content

sendfun_sdk/transfer_hook/
route.rs

1//! Checks for a router that cannot simulate a trade. The builders do not run
2//! them.
3//!
4//! [`trade`] resolves one trade direction from the [`MintInfo`] of each mint
5//! and the router's cache. It refuses the direction for:
6//!
7//! - a `MintInfo` of another mint, a base mint that is not a Token-2022 mint,
8//!   a quote token program that is not the quote mint's, or a `MintInfo` with
9//!   a hook program and no hook;
10//! - the resolver's refusals ([`HookError`]);
11//! - a hook extra that wants writable an account that the chain holds
12//!   read-only on a trade ([`Unroutable::WritableRepeat`]);
13//! - a list that wants an extra to sign, or a list whose `Execute` holds an
14//!   account in a role that the list does not declare. A hook that runs SPL's
15//!   `check_account_infos` fails with `0xa261c2c0`.
16//!
17//! [`screen`] judges one direction for any trader. It runs [`trade`] for two
18//! stand-in traders.
19//!
20//! [`MarketLoad`] loads and screens a market at each update. It names the
21//! accounts to fetch, and gives each direction's [`Screen`], the cache that a
22//! swap resolves from, and the accounts to declare for the next update.
23
24use solana_address::Address;
25use solana_instruction::Instruction;
26
27use super::{Fetched, FetchedAccount, HookError, HookState, LegExecute, Step};
28use crate::utils::{
29	MintInfo, TradeAccounts, TradeDirection, TradeLegInput, TradeMints,
30	TradeMode, check_base_mint, legs_of_trade,
31};
32use crate::{dex, launchpad};
33
34/// Why a router leaves a trade direction out.
35#[derive(Clone, Debug, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum Unroutable {
38	Hook(HookError),
39	/// The lists read the data of these accounts, which the router has not
40	/// cached. Add them to the cache for the next update.
41	NotCached(Vec<Address>),
42	/// A list reads the data of an account that depends on the trader.
43	/// [`trade`] does not return it. A router returns it when the reads change
44	/// with the trader.
45	UncacheableRead,
46	/// A list wants an extra to sign. Token-2022 clears the signer bit, and
47	/// the hook's own check refuses all transfers of the mint.
48	ExtraSigns,
49	/// The hook's `Execute` holds an account in a role its list does not
50	/// declare. In one case, an extra wants writable a read-only account of
51	/// the `Execute`: its source, mint, destination, authority, validation
52	/// account or an earlier read-only extra. In the other case, an extra
53	/// wants read-only an account that an earlier extra holds writable.
54	ExecuteRoleConflict,
55	/// A hook extra wants writable an account that the chain holds read-only
56	/// on a trade. A trade holds the market, the mints and the partner config
57	/// read-only. The runtime holds the system program (also
58	/// `DEFAULT_PARTNER`), the sysvars and the native programs read-only in
59	/// all transactions.
60	///
61	/// It does not check Token-2022, the associated token program or the
62	/// market's own program.
63	WritableRepeat(Address),
64	/// The `MintInfo` for this mint of the trade is the info of another mint.
65	MintMismatch(Address),
66	/// The base mint is not a Token-2022 mint.
67	BaseNotToken2022(Address),
68	/// The trade's `quote_token_program` is not the token program of the
69	/// `MintInfo` of this quote mint. The program refuses the trade.
70	QuoteTokenProgramMismatch(Address),
71	/// The `MintInfo` of this mint has a hook program and no hook. The list
72	/// comes only from the `MintInfo`, never from the router's cache. Read it
73	/// with [`MintInfo::load`].
74	HookNotRead(Address),
75}
76
77impl std::fmt::Display for Unroutable {
78	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79		match self {
80			Self::Hook(error) => error.fmt(f),
81			Self::NotCached(_) => {
82				write!(f, "transfer hook accounts not cached")
83			}
84			Self::UncacheableRead => write!(
85				f,
86				"a transfer hook reads an account that depends on the trader"
87			),
88			Self::ExtraSigns => {
89				write!(f, "a transfer hook extra must sign")
90			}
91			Self::ExecuteRoleConflict => write!(
92				f,
93				"a transfer hook wants one account of its Execute both \
94				 read-only and writable"
95			),
96			Self::WritableRepeat(account) => write!(
97				f,
98				"a transfer hook wants {account} writable, which the trade \
99				 holds read-only"
100			),
101			Self::MintMismatch(mint) => write!(
102				f,
103				"the mint info is not the info of the trade's mint {mint}"
104			),
105			Self::BaseNotToken2022(mint) => {
106				write!(f, "the base mint {mint} is not a Token-2022 mint")
107			}
108			Self::QuoteTokenProgramMismatch(mint) => write!(
109				f,
110				"the quote token program is not the token program of the quote \
111				 mint {mint}"
112			),
113			Self::HookNotRead(mint) => {
114				write!(f, "the mint {mint} has a hook program and no hook")
115			}
116		}
117	}
118}
119
120impl std::error::Error for Unroutable {}
121
122impl From<HookError> for Unroutable {
123	fn from(error: HookError) -> Self {
124		Self::Hook(error)
125	}
126}
127
128/// A routable trade: its instruction with the hook accounts, and the accounts
129/// whose data a list read. Cache these accounts for the next update.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct Route {
132	pub instruction: Instruction,
133	pub reads: Vec<Address>,
134}
135
136/// The program a market trades on.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
138pub enum TradeProgram {
139	Launchpad,
140	Dex,
141}
142
143/// One trade, as a router would send it.
144#[derive(Clone, Copy, Debug)]
145pub struct TradeRoute<'r> {
146	pub program: TradeProgram,
147	pub trade: &'r TradeAccounts,
148	/// The mint at `trade.base_mint`, from [`MintInfo::load`].
149	pub base_mint_info: &'r MintInfo,
150	/// The mint at `trade.quote_mint`, from [`MintInfo::load`].
151	pub quote_mint_info: &'r MintInfo,
152	pub direction: TradeDirection,
153	pub mode: TradeMode,
154	/// `amount_in` for `ExactIn`. `amount_out` for `ExactOut`.
155	pub amount: u64,
156	/// `min_amount_out` for `ExactIn`. `max_amount_in` for `ExactOut`.
157	pub limit: u64,
158}
159
160impl TradeRoute<'_> {
161	/// The trade instruction without hook accounts, and its legs.
162	fn build(&self) -> (Instruction, super::HookLegs) {
163		let input = TradeLegInput {
164			accounts: *self.trade,
165			direction: self.direction,
166		};
167		match self.program {
168			TradeProgram::Launchpad => (
169				launchpad::trade::trade_instruction(
170					self.trade,
171					launchpad::trade::TradeArgs {
172						direction: self.direction,
173						mode: self.mode,
174						amount: self.amount,
175						limit: self.limit,
176					},
177				),
178				legs_of_trade(&input, launchpad::trade::WRITES_MARKET),
179			),
180			TradeProgram::Dex => (
181				dex::trade::trade_instruction(
182					self.trade,
183					dex::trade::TradeArgs {
184						direction: self.direction.into(),
185						mode: self.mode,
186						amount: self.amount,
187						limit: self.limit,
188					},
189				),
190				legs_of_trade(&input, dex::trade::WRITES_MARKET),
191			),
192		}
193	}
194}
195
196/// Keys the runtime holds read-only in every transaction (agave's reserved
197/// account keys): the system program, the sysvars and the native programs.
198const DEMOTED: [Address; 31] = [
199	solana_address::address!("11111111111111111111111111111111"),
200	solana_address::address!("NativeLoader1111111111111111111111111111111"),
201	solana_address::address!("BPFLoader1111111111111111111111111111111111"),
202	solana_address::address!("BPFLoader2111111111111111111111111111111111"),
203	solana_address::address!("BPFLoaderUpgradeab1e11111111111111111111111"),
204	solana_address::address!("LoaderV411111111111111111111111111111111111"),
205	solana_address::address!("ComputeBudget111111111111111111111111111111"),
206	solana_address::address!("AddressLookupTab1e1111111111111111111111111"),
207	solana_address::address!("Config1111111111111111111111111111111111111"),
208	solana_address::address!("Stake11111111111111111111111111111111111111"),
209	solana_address::address!("StakeConfig11111111111111111111111111111111"),
210	solana_address::address!("Vote111111111111111111111111111111111111111"),
211	solana_address::address!("Ed25519SigVerify111111111111111111111111111"),
212	solana_address::address!("KeccakSecp256k11111111111111111111111111111"),
213	solana_address::address!("Secp256r1SigVerify1111111111111111111111111"),
214	solana_address::address!("Feature111111111111111111111111111111111111"),
215	solana_address::address!("ZkE1Gama1Proof11111111111111111111111111111"),
216	solana_address::address!("ZkTokenProof1111111111111111111111111111111"),
217	solana_address::address!("Sysvar1111111111111111111111111111111111111"),
218	solana_address::address!("SysvarC1ock11111111111111111111111111111111"),
219	solana_address::address!("SysvarEpochRewards1111111111111111111111111"),
220	solana_address::address!("SysvarEpochSchedu1e111111111111111111111111"),
221	solana_address::address!("SysvarFees111111111111111111111111111111111"),
222	solana_address::address!("Sysvar1nstructions1111111111111111111111111"),
223	solana_address::address!("SysvarLastRestartS1ot1111111111111111111111"),
224	solana_address::address!("SysvarRecentB1ockHashes11111111111111111111"),
225	solana_address::address!("SysvarRent111111111111111111111111111111111"),
226	solana_address::address!("SysvarRewards111111111111111111111111111111"),
227	solana_address::address!("SysvarS1otHashes111111111111111111111111111"),
228	solana_address::address!("SysvarS1otHistory11111111111111111111111111"),
229	solana_address::address!("SysvarStakeHistory1111111111111111111111111"),
230];
231
232/// Resolves one trade direction and checks it.
233///
234/// Each hooked mint's list comes from its [`MintInfo`]'s [`HookState`]
235/// (`MintInfo::missing` names what to insert). A list read looks in both
236/// infos, then in `accounts`, the router's cache: the market's PDAs and the
237/// trader's accounts. An account in neither gives [`Unroutable::NotCached`].
238/// A hook-free mint's info holds no account, its own included.
239///
240/// [`HookState`]: super::HookState
241pub fn trade<'a>(
242	route: TradeRoute<'_>,
243	accounts: impl Fn(&Address) -> Fetched<'a>,
244) -> Result<Route, Unroutable> {
245	let trade = route.trade;
246	let mints = TradeMints {
247		base: route.base_mint_info,
248		quote: route.quote_mint_info,
249	};
250	check_mints(&route)?;
251	let (mut instruction, legs) = route.build();
252	let resolved = match super::resolve_market(mints, &legs, accounts)? {
253		Step::Done(resolved) => resolved,
254		Step::Fetch(addresses) => return Err(Unroutable::NotCached(addresses)),
255	};
256	for execute in &resolved.executes {
257		screen_execute(execute)?;
258	}
259	let read_only = [
260		trade.market,
261		trade.base_mint,
262		trade.quote_mint,
263		trade.partner_config,
264	];
265	if let Some(meta) = resolved.accounts.iter().find(|meta| {
266		meta.is_writable
267			&& (read_only.contains(&meta.pubkey)
268				|| DEMOTED.contains(&meta.pubkey))
269	}) {
270		return Err(Unroutable::WritableRepeat(meta.pubkey));
271	}
272	instruction.accounts.extend(resolved.accounts);
273	Ok(Route {
274		instruction,
275		reads: resolved.reads,
276	})
277}
278
279/// One trade direction as [`screen`] judges it for any trader.
280#[derive(Clone, Debug, PartialEq, Eq)]
281#[non_exhaustive]
282pub struct Screen {
283	/// `Ok` when the direction routes for any trader.
284	pub route: Result<(), Unroutable>,
285	/// The accounts that both stand-ins need and the cache lacks. Fetch them,
286	/// add them to the cache and screen again.
287	pub not_cached: Vec<Address>,
288	/// [`trade`] for each stand-in trader.
289	pub stand_ins: [Result<Route, Unroutable>; 2],
290}
291
292/// Two traders that no wallet holds. A list that reads an account whose key
293/// or data differs between them depends on the trader.
294const STAND_INS: [u8; 2] = [1, 5];
295
296/// `trade` with a trader that no wallet holds. Its user, payer, base account
297/// and quote account keys are arrays of `seed`, `seed + 1`, `seed + 2` and
298/// `seed + 3`.
299const fn stand_in(trade: &TradeAccounts, seed: u8) -> TradeAccounts {
300	TradeAccounts {
301		user: Address::new_from_array([seed; 32]),
302		payer: Address::new_from_array([seed.wrapping_add(1); 32]),
303		user_base_account: Address::new_from_array([seed.wrapping_add(2); 32]),
304		user_quote_account: Address::new_from_array([seed.wrapping_add(3); 32]),
305		..*trade
306	}
307}
308
309/// Judges one trade direction for any trader. The trader accounts of
310/// `route.trade` are not read.
311///
312/// It runs [`trade`] for two stand-in traders. The direction routes when both
313/// resolve it with the same reads. When both stand-ins lack the same accounts,
314/// it gives [`Unroutable::NotCached`]. When both fail alike, it gives that
315/// error. When the stand-ins resolve differently, the lists depend on the
316/// trader: [`Unroutable::UncacheableRead`].
317///
318/// A screen does not name the accounts that it found in `accounts`. A router
319/// that serves only declared accounts must screen against a cache of only the
320/// accounts that `not_cached` named in this update. It must declare each of
321/// them for the next update. [`MarketLoad`] does this.
322pub fn screen<'a>(
323	route: TradeRoute<'_>,
324	accounts: impl Fn(&Address) -> Fetched<'a>,
325) -> Screen {
326	let stand_ins = STAND_INS.map(|seed| {
327		trade(
328			TradeRoute {
329				trade: &stand_in(route.trade, seed),
330				..route
331			},
332			&accounts,
333		)
334	});
335	let (route, not_cached) = match &stand_ins {
336		[Ok(first), Ok(second)] => (
337			if same(&first.reads, &second.reads) {
338				Ok(())
339			} else {
340				Err(Unroutable::UncacheableRead)
341			},
342			Vec::new(),
343		),
344		[
345			Err(Unroutable::NotCached(first)),
346			Err(Unroutable::NotCached(second)),
347		] if same(first, second) => {
348			(Err(Unroutable::NotCached(first.clone())), first.clone())
349		}
350		[Err(first), Err(second)] if first == second => {
351			(Err(first.clone()), Vec::new())
352		}
353		_ => (Err(Unroutable::UncacheableRead), Vec::new()),
354	};
355	Screen {
356		route,
357		not_cached,
358		stand_ins,
359	}
360}
361
362/// Both trade directions, in the order of [`LoadedMarket::screens`].
363const DIRECTIONS: [TradeDirection; 2] =
364	[TradeDirection::Buy, TradeDirection::Sell];
365
366/// Loads and screens one market at each update, for a router.
367///
368/// Make a new load at each update, with the mints just read. Fetch what
369/// [`missing`](Self::missing) names, [`insert`](Self::insert) each account
370/// that the fetch gives, and call `missing` again. When it is empty, call
371/// [`finish`](Self::finish). `missing` names each account once. Insert
372/// nothing for an account that the router cannot fetch: a direction that
373/// reads it does not route.
374///
375/// Declare [`LoadedMarket::declare`] for the next update. A router that
376/// serves only declared accounts then loads one more link of a chain of reads
377/// at each update.
378#[derive(Clone, Debug)]
379pub struct MarketLoad {
380	program: TradeProgram,
381	market: TradeAccounts,
382	/// The base mint, then the quote mint.
383	mints: [MintInfo; 2],
384	/// Each account inserted in this load. The screens read only these.
385	cache: HookState,
386	/// Each account that `missing` named.
387	named: Vec<Address>,
388}
389
390/// What [`MarketLoad::new`] takes.
391#[derive(Clone, Debug)]
392pub struct MarketLoadArgs {
393	pub program: TradeProgram,
394	/// The market's side of a trade. The load does not read its trader
395	/// accounts.
396	pub market: TradeAccounts,
397	/// The mints of this update, from [`MintInfo::load`].
398	pub base_mint_info: MintInfo,
399	pub quote_mint_info: MintInfo,
400}
401
402impl MarketLoad {
403	#[must_use]
404	pub fn new(args: MarketLoadArgs) -> Self {
405		Self {
406			program: args.program,
407			market: args.market,
408			mints: [args.base_mint_info, args.quote_mint_info],
409			cache: HookState::default(),
410			named: Vec::new(),
411		}
412	}
413
414	/// The accounts to fetch next: what [`MintInfo::missing`] names for each
415	/// mint, and what the screens lack. It leaves out each account that it
416	/// named before. Empty when the load can learn nothing more.
417	///
418	/// # Errors
419	///
420	/// The error of [`MintInfo::missing`].
421	pub fn missing(&mut self) -> Result<Vec<Address>, HookError> {
422		let mut next: Vec<Address> = Vec::new();
423		for info in &self.mints {
424			push_new(&mut next, &info.missing()?);
425		}
426		for screen in self.screens().iter().flatten() {
427			push_new(&mut next, &screen.not_cached);
428		}
429		next.retain(|address| !self.named.contains(address));
430		push_new(&mut self.named, &next);
431		Ok(next)
432	}
433
434	/// Records what a fetch of `address` gave: `None` for no account. A mint
435	/// whose [`MintInfo::missing`] names it also holds it.
436	pub fn insert(
437		&mut self,
438		address: Address,
439		account: Option<FetchedAccount>,
440	) {
441		for info in &mut self.mints {
442			if info
443				.missing()
444				.is_ok_and(|missing| missing.contains(&address))
445			{
446				info.insert(address, account.clone());
447			}
448		}
449		self.cache.insert(address, account);
450	}
451
452	#[must_use]
453	pub fn finish(self) -> LoadedMarket {
454		let screens = self.screens();
455		let [base_mint_info, quote_mint_info] = self.mints;
456		LoadedMarket {
457			base_mint_info,
458			quote_mint_info,
459			cache: self.cache,
460			screens,
461			declare: self.named,
462		}
463	}
464
465	/// Each direction, buy first, screened against the inserted accounts.
466	/// `None` when neither mint has a hook.
467	fn screens(&self) -> Option<[Screen; 2]> {
468		let [base_mint_info, quote_mint_info] = &self.mints;
469		if base_mint_info.hook_program.is_none()
470			&& quote_mint_info.hook_program.is_none()
471		{
472			return None;
473		}
474		Some(DIRECTIONS.map(|direction| {
475			screen(
476				TradeRoute {
477					program: self.program,
478					trade: &self.market,
479					base_mint_info,
480					quote_mint_info,
481					direction,
482					// Both modes resolve alike: no leg's amount is known.
483					mode: TradeMode::ExactIn,
484					// `trade` refuses a list that reads the amount.
485					amount: 0,
486					limit: 0,
487				},
488				|address| self.cache.fetched(address),
489			)
490		}))
491	}
492}
493
494/// A market as a [`MarketLoad`] read it.
495#[derive(Clone, Debug, PartialEq, Eq)]
496#[non_exhaustive]
497pub struct LoadedMarket {
498	pub base_mint_info: MintInfo,
499	pub quote_mint_info: MintInfo,
500	/// Each account that the load fetched. A swap resolves from it and the
501	/// mint infos with [`trade`].
502	pub cache: HookState,
503	/// The buy, then the sell, as [`screen`] judges each for any trader.
504	/// `None` when neither mint has a hook: every trader builds the same
505	/// hook-free trade.
506	pub screens: Option<[Screen; 2]>,
507	/// Each account that the load named. Declare them for the next update.
508	pub declare: Vec<Address>,
509}
510
511fn push_new(all: &mut Vec<Address>, keys: &[Address]) {
512	for key in keys {
513		if !all.contains(key) {
514			all.push(*key);
515		}
516	}
517}
518
519/// The same accounts, in any order.
520fn same(first: &[Address], second: &[Address]) -> bool {
521	first.len() == second.len() && first.iter().all(|key| second.contains(key))
522}
523
524/// Each info must be the info of the trade's mint, before any other check reads
525/// it.
526fn check_mints(route: &TradeRoute<'_>) -> Result<(), Unroutable> {
527	let infos = [
528		(route.trade.base_mint, route.base_mint_info),
529		(route.trade.quote_mint, route.quote_mint_info),
530	];
531	if let Some((mint, _)) =
532		infos.iter().find(|(mint, info)| info.address != *mint)
533	{
534		return Err(Unroutable::MintMismatch(*mint));
535	}
536	if check_base_mint(route.base_mint_info).is_err() {
537		return Err(Unroutable::BaseNotToken2022(route.trade.base_mint));
538	}
539	if route.trade.quote_token_program != route.quote_mint_info.token_program {
540		return Err(Unroutable::QuoteTokenProgramMismatch(
541			route.trade.quote_mint,
542		));
543	}
544	if let Some((mint, _)) = infos.iter().find(|(_, info)| info.hook_not_read())
545	{
546		return Err(Unroutable::HookNotRead(*mint));
547	}
548	Ok(())
549}
550
551/// The hook's `check_account_infos`: each extra must reach it as a
552/// non-signer with the writable flag its list declares. The runtime gives an
553/// account the roles of all its repeats in the `Execute`.
554fn screen_execute(execute: &LegExecute) -> Result<(), Unroutable> {
555	let extras = execute.metas.iter().skip(super::EXECUTE_ACCOUNTS);
556	for (meta, (signer, writable)) in extras.zip(&execute.declared) {
557		if *signer {
558			return Err(Unroutable::ExtraSigns);
559		}
560		let held = execute
561			.metas
562			.iter()
563			.any(|other| other.pubkey == meta.pubkey && other.is_writable);
564		if held != *writable {
565			return Err(Unroutable::ExecuteRoleConflict);
566		}
567	}
568	Ok(())
569}
570
571#[cfg(test)]
572mod tests;