Skip to main content

sendfun_sdk/
transfer_fee.rs

1//! Reads the transfer fee and the pause of a Token-2022 mint from raw account
2//! data.
3//!
4//! The fee is for the [`math::amm`](crate::math::amm) quotes. Cache a
5//! `TransferFeeConfig` only while its `transfer_fee_config_authority` is
6//! `None`.
7
8use bytemuck::Pod;
9use solana_address::Address;
10use solana_program::program_error::ProgramError;
11use spl_token_2022_interface::error::TokenError;
12use spl_token_2022_interface::extension::pausable::PausableConfig;
13use spl_token_2022_interface::extension::{
14	AccountType, BaseStateWithExtensions, Extension, PodStateWithExtensions,
15};
16use spl_token_2022_interface::pod::PodMint;
17
18use crate::constants::{TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID};
19use crate::math::amm::MintFee;
20
21pub use spl_token_2022_interface::extension::transfer_fee::{
22	TransferFee, TransferFeeConfig,
23};
24
25/// A TLV entry header: the `u16` type, then the `u16` length.
26const TLV_HEADER_LEN: usize = 4;
27
28/// Unpacks a mint and its extensions.
29///
30/// `Ok(None)` for an SPL Token mint, which has none. Fails with
31/// `ProgramError::IncorrectProgramId` if no token program owns the mint, and
32/// with another error if its data is not an initialized mint.
33pub fn mint_extensions<'a>(
34	data: &'a [u8],
35	owner: &Address,
36) -> Result<Option<PodStateWithExtensions<'a, PodMint>>, ProgramError> {
37	if owner == &TOKEN_PROGRAM_ID {
38		spl_token_mint(data)?;
39		return Ok(None);
40	}
41	if owner != &TOKEN_2022_PROGRAM_ID {
42		return Err(ProgramError::IncorrectProgramId);
43	}
44	PodStateWithExtensions::<PodMint>::unpack(data).map(Some)
45}
46
47/// An SPL Token mint: the base mint of Token-2022, with nothing after it.
48/// `unpack` refuses one that is not initialized.
49pub(crate) fn spl_token_mint(data: &[u8]) -> Result<&PodMint, ProgramError> {
50	if data.len() != size_of::<PodMint>() {
51		return Err(ProgramError::InvalidAccountData);
52	}
53	Ok(PodStateWithExtensions::<PodMint>::unpack(data)?.base)
54}
55
56/// Reads the mint extension `V`.
57///
58/// `Ok(None)` only when the mint does not have `V`. A malformed extension
59/// region is an error, not `Ok(None)`. An account extension type fails with
60/// `ProgramError::InvalidAccountData`.
61pub fn extension<'a, V: Extension + Pod>(
62	mint: &'a PodStateWithExtensions<'_, PodMint>,
63) -> Result<Option<&'a V>, ProgramError> {
64	match mint.get_extension::<V>() {
65		Ok(value) => Ok(Some(value)),
66		// The walk reached a zero type before `V`.
67		Err(error) if error == TokenError::ExtensionNotFound.into() => Ok(None),
68		// The walk ran off the end, the data is cut, or `V` is an account
69		// extension. Token-2022 allocates the exact size, so a clean end means
70		// `V` is absent.
71		Err(ProgramError::InvalidAccountData)
72			if V::TYPE.get_account_type() == AccountType::Mint
73				&& ends_cleanly(mint.get_tlv_data()) =>
74		{
75			Ok(None)
76		}
77		Err(error) => Err(error),
78	}
79}
80
81/// `true` if every TLV entry is whole, as SPL walks the entries.
82/// Does not parse types as `ExtensionType`: an unknown type must not make a
83/// fee-free mint unreadable.
84fn ends_cleanly(tlv: &[u8]) -> bool {
85	let mut rest = tlv;
86	loop {
87		let Some(&[type_low, type_high]) = rest.get(..2) else {
88			return true;
89		};
90		if u16::from_le_bytes([type_low, type_high]) == 0 {
91			return true;
92		}
93		let Some(&[length_low, length_high]) = rest.get(2..TLV_HEADER_LEN)
94		else {
95			return false;
96		};
97		let length = usize::from(u16::from_le_bytes([length_low, length_high]));
98		let Some(next) = TLV_HEADER_LEN
99			.checked_add(length)
100			.and_then(|end| rest.get(end..))
101		else {
102			return false;
103		};
104		rest = next;
105	}
106}
107
108#[must_use]
109pub fn mint_fee(fee: &TransferFee) -> MintFee {
110	MintFee {
111		bps: fee.transfer_fee_basis_points.into(),
112		maximum_fee: fee.maximum_fee.into(),
113	}
114}
115
116/// The fees of a mint's `TransferFeeConfig`, and the epoch of the read.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub struct FeeSchedule {
119	pub older: MintFee,
120	pub newer: MintFee,
121	/// The first epoch of `newer`.
122	pub newer_epoch: u64,
123	/// The epoch the mint was read at.
124	pub read_epoch: u64,
125}
126
127impl FeeSchedule {
128	/// The schedule of `config`, read at `read_epoch`.
129	pub(crate) fn of(config: &TransferFeeConfig, read_epoch: u64) -> Self {
130		Self {
131			older: mint_fee(&config.older_transfer_fee),
132			newer: mint_fee(&config.newer_transfer_fee),
133			newer_epoch: config.newer_transfer_fee.epoch.into(),
134			read_epoch,
135		}
136	}
137
138	/// The fee at `read_epoch`. Builders use this.
139	#[must_use]
140	pub const fn fee(&self) -> MintFee {
141		self.fee_at(self.read_epoch)
142	}
143
144	/// The fee at `epoch`, as SPL `get_epoch_fee`: `newer` when
145	/// `epoch >= newer_epoch`, else `older`.
146	#[must_use]
147	pub const fn fee_at(&self, epoch: u64) -> MintFee {
148		if epoch >= self.newer_epoch {
149			self.newer
150		} else {
151			self.older
152		}
153	}
154}
155
156/// The transfer fee of a mint at `epoch`. `Ok(None)` only when the mint has no
157/// `TransferFeeConfig`. A 0 bps config gives `Some`.
158pub fn mint_fee_at_epoch(
159	data: &[u8],
160	owner: &Address,
161	epoch: u64,
162) -> Result<Option<MintFee>, ProgramError> {
163	let Some(mint) = mint_extensions(data, owner)? else {
164		return Ok(None);
165	};
166	Ok(extension::<TransferFeeConfig>(&mint)?
167		.map(|config| mint_fee(config.get_epoch_fee(epoch))))
168}
169
170/// `true` when the mint's `PausableConfig` is paused. Token-2022 then fails
171/// every transfer of the mint. An SPL Token mint cannot be paused.
172pub fn mint_paused(data: &[u8], owner: &Address) -> Result<bool, ProgramError> {
173	let Some(mint) = mint_extensions(data, owner)? else {
174		return Ok(false);
175	};
176	Ok(extension::<PausableConfig>(&mint)?
177		.is_some_and(|config| bool::from(config.paused)))
178}
179
180#[cfg(test)]
181mod tests {
182	use super::*;
183
184	use spl_token_2022_interface::extension::transfer_fee::TransferFeeAmount;
185
186	/// SPL's `unpack` refuses a mint with this byte unset.
187	const IS_INITIALIZED_OFFSET: usize = 45;
188
189	/// Token-2022 pads the mint to the token-account length before this byte.
190	const ACCOUNT_TYPE_OFFSET: usize = 165;
191
192	const TLV_START: usize = 166;
193
194	const FEE_TYPE: u16 = 1;
195
196	/// Above every type that SPL 3.1.1 defines.
197	const FUTURE_TYPE: u16 = 250;
198
199	const NEWER: Option<MintFee> = Some(MintFee {
200		bps: 250,
201		maximum_fee: 2_000,
202	});
203
204	fn entry(epoch: u64, maximum_fee: u64, basis_points: u16) -> TransferFee {
205		TransferFee {
206			epoch: epoch.into(),
207			maximum_fee: maximum_fee.into(),
208			transfer_fee_basis_points: basis_points.into(),
209		}
210	}
211
212	fn fee_payload(authority: Option<Address>) -> Vec<u8> {
213		bytemuck::bytes_of(&TransferFeeConfig {
214			transfer_fee_config_authority: authority.try_into().unwrap(),
215			older_transfer_fee: entry(5, 1_000, 100),
216			newer_transfer_fee: entry(7, 2_000, 250),
217			..TransferFeeConfig::default()
218		})
219		.to_vec()
220	}
221
222	/// `MetadataPointer`, then `TokenMetadata`. `create_token` writes both.
223	fn other_extensions() -> Vec<(u16, Vec<u8>)> {
224		vec![(18, vec![0; 64]), (19, vec![0; 90])]
225	}
226
227	fn tlv_image(entries: &[(u16, Vec<u8>)]) -> Vec<u8> {
228		let mut data = vec![0_u8; ACCOUNT_TYPE_OFFSET];
229		data[IS_INITIALIZED_OFFSET] = 1;
230		// `AccountType::Mint`.
231		data.push(1);
232		for (extension_type, payload) in entries {
233			data.extend_from_slice(&extension_type.to_le_bytes());
234			data.extend_from_slice(
235				&u16::try_from(payload.len()).unwrap().to_le_bytes(),
236			);
237			data.extend_from_slice(payload);
238		}
239		data
240	}
241
242	fn fee(data: &[u8]) -> Result<Option<MintFee>, ProgramError> {
243		mint_fee_at_epoch(data, &TOKEN_2022_PROGRAM_ID, 7)
244	}
245
246	#[test]
247	fn the_walk_finds_the_config_wherever_it_sits() {
248		for at in 0..=2 {
249			let mut entries = other_extensions();
250			entries.insert(at, (FEE_TYPE, fee_payload(None)));
251			assert_eq!(fee(&tlv_image(&entries)), Ok(NEWER), "at {at}");
252		}
253	}
254
255	#[test]
256	fn the_owner_picks_the_reader() {
257		let image = tlv_image(&[(FEE_TYPE, fee_payload(None))]);
258		assert_eq!(fee(&image), Ok(NEWER));
259		// An SPL Token mint is the 82-byte base mint alone: no extension.
260		let mut spl_mint = [0; 82];
261		spl_mint[45] = 1;
262		assert_eq!(
263			mint_fee_at_epoch(&spl_mint, &TOKEN_PROGRAM_ID, 7),
264			Ok(None)
265		);
266		// SPL Token never writes a TLV region.
267		assert_eq!(
268			mint_fee_at_epoch(&image, &TOKEN_PROGRAM_ID, 7),
269			Err(ProgramError::InvalidAccountData)
270		);
271		assert_eq!(
272			mint_fee_at_epoch(&spl_mint.map(|_| 0), &TOKEN_PROGRAM_ID, 7),
273			Err(ProgramError::UninitializedAccount)
274		);
275		assert_eq!(
276			mint_fee_at_epoch(&image, &crate::constants::SYSTEM_PROGRAM_ID, 7),
277			Err(ProgramError::IncorrectProgramId)
278		);
279	}
280
281	const PAUSABLE_TYPE: u16 = 26;
282
283	fn pausable_payload(paused: bool) -> Vec<u8> {
284		bytemuck::bytes_of(&PausableConfig {
285			paused: paused.into(),
286			..PausableConfig::default()
287		})
288		.to_vec()
289	}
290
291	#[test]
292	fn the_pause_reads_off_the_pausable_config() {
293		for paused in [false, true] {
294			let mut entries = other_extensions();
295			entries.push((PAUSABLE_TYPE, pausable_payload(paused)));
296			assert_eq!(
297				mint_paused(&tlv_image(&entries), &TOKEN_2022_PROGRAM_ID),
298				Ok(paused),
299				"paused {paused}"
300			);
301		}
302		assert_eq!(
303			mint_paused(
304				&tlv_image(&other_extensions()),
305				&TOKEN_2022_PROGRAM_ID
306			),
307			Ok(false)
308		);
309	}
310
311	#[test]
312	fn the_owner_picks_the_pause_reader() {
313		let mut spl_mint = [0; 82];
314		spl_mint[IS_INITIALIZED_OFFSET] = 1;
315		assert_eq!(mint_paused(&spl_mint, &TOKEN_PROGRAM_ID), Ok(false));
316		let paused = tlv_image(&[(PAUSABLE_TYPE, pausable_payload(true))]);
317		assert_eq!(
318			mint_paused(&paused, &crate::constants::SYSTEM_PROGRAM_ID),
319			Err(ProgramError::IncorrectProgramId)
320		);
321	}
322
323	#[test]
324	fn the_newer_entry_applies_from_its_own_epoch() {
325		let image = tlv_image(&[(FEE_TYPE, fee_payload(None))]);
326		assert_eq!(
327			mint_fee_at_epoch(&image, &TOKEN_2022_PROGRAM_ID, 6),
328			Ok(Some(MintFee {
329				bps: 100,
330				maximum_fee: 1_000,
331			}))
332		);
333		assert_eq!(fee(&image), Ok(NEWER));
334	}
335
336	#[test]
337	fn a_revoked_authority_reads_as_none() {
338		for authority in [None, Some(Address::new_from_array([9; 32]))] {
339			let image = tlv_image(&[(FEE_TYPE, fee_payload(authority))]);
340			let mint = mint_extensions(&image, &TOKEN_2022_PROGRAM_ID)
341				.unwrap()
342				.unwrap();
343			let config =
344				extension::<TransferFeeConfig>(&mint).unwrap().unwrap();
345			assert_eq!(
346				Option::from(config.transfer_fee_config_authority),
347				authority
348			);
349		}
350	}
351
352	#[test]
353	fn a_bare_mint_has_no_tlv_region_to_walk() {
354		let mut bare = tlv_image(&[]);
355		bare.truncate(82);
356		assert_eq!(fee(&bare), Ok(None));
357		bare[IS_INITIALIZED_OFFSET] = 0;
358		assert_eq!(fee(&bare), Err(ProgramError::UninitializedAccount));
359	}
360
361	/// Extensions that total `Multisig::LEN` get two bytes of padding. SPL
362	/// reads one trailing byte as slack.
363	#[test]
364	fn a_region_that_ends_cleanly_carries_no_config() {
365		for tail in [
366			vec![],
367			vec![0],
368			vec![0; 2],
369			vec![0; 3],
370			vec![0; 512],
371			vec![7],
372		] {
373			let mut image = tlv_image(&other_extensions());
374			image.extend_from_slice(&tail);
375			assert_eq!(fee(&image), Ok(None), "tail {tail:?}");
376		}
377	}
378
379	/// Token-2022 also stops at the first match. It does not read the entries
380	/// after it.
381	#[test]
382	fn a_cut_entry_past_the_config_is_not_read() {
383		let mut image = tlv_image(&[(FEE_TYPE, fee_payload(None))]);
384		image.extend_from_slice(&[18, 0, 64, 0]);
385		assert_eq!(fee(&image), Ok(NEWER));
386	}
387
388	/// An account extension on a mint is a caller error, not an absent
389	/// extension.
390	#[test]
391	fn an_account_extension_is_not_read_off_a_mint() {
392		let image = tlv_image(&other_extensions());
393		let mint = mint_extensions(&image, &TOKEN_2022_PROGRAM_ID)
394			.unwrap()
395			.unwrap();
396		assert_eq!(
397			extension::<TransferFeeAmount>(&mint),
398			Err(ProgramError::InvalidAccountData)
399		);
400	}
401
402	/// SPL's `get_extension_types` refuses unknown types. The region check must
403	/// not use it.
404	#[test]
405	fn a_future_extension_type_leaves_the_mint_readable() {
406		let future = (FUTURE_TYPE, vec![0; 16]);
407		let fee_free = tlv_image(&[(18, vec![0; 64]), future.clone()]);
408		let mint = mint_extensions(&fee_free, &TOKEN_2022_PROGRAM_ID)
409			.unwrap()
410			.unwrap();
411		assert_eq!(
412			mint.get_extension_types(),
413			Err(ProgramError::InvalidAccountData)
414		);
415		assert_eq!(extension::<TransferFeeConfig>(&mint), Ok(None));
416		let fee_after = tlv_image(&[future, (FEE_TYPE, fee_payload(None))]);
417		assert_eq!(fee(&fee_after), Ok(NEWER));
418	}
419
420	#[test]
421	fn a_cut_region_is_malformed() {
422		let mut short = tlv_image(&[]);
423		short.truncate(ACCOUNT_TYPE_OFFSET);
424		let mut header = tlv_image(&other_extensions());
425		header.truncate(TLV_START + 4 + 64 + 2);
426		let mut overrun = tlv_image(&[(18, vec![0; 64])]);
427		overrun.truncate(TLV_START + 4 + 32);
428		let mut payload = tlv_image(&other_extensions());
429		payload.extend_from_slice(&[18, 0, 64, 0]);
430		let mut config = tlv_image(&[(FEE_TYPE, fee_payload(None))]);
431		config.pop();
432		for (label, image) in [
433			("no account type", short),
434			("cut header", header),
435			("length past the end", overrun),
436			("payload never arrives", payload),
437			("config payload cut", config),
438		] {
439			assert_eq!(
440				fee(&image),
441				Err(ProgramError::InvalidAccountData),
442				"{label}"
443			);
444		}
445	}
446
447	#[test]
448	fn a_config_payload_of_the_wrong_length_is_malformed() {
449		for length in [100_usize, 116] {
450			let image = tlv_image(&[(FEE_TYPE, vec![0; length])]);
451			assert_eq!(
452				fee(&image),
453				Err(ProgramError::InvalidArgument),
454				"payload length {length}"
455			);
456		}
457	}
458
459	const OLDER: MintFee = MintFee {
460		bps: 100,
461		maximum_fee: 1_000,
462	};
463
464	/// The schedule of [`fee_payload`]: `OLDER` before epoch 7, then `NEWER`.
465	fn schedule(read_epoch: u64) -> FeeSchedule {
466		let config: TransferFeeConfig =
467			bytemuck::pod_read_unaligned(&fee_payload(None));
468		FeeSchedule::of(&config, read_epoch)
469	}
470
471	#[test]
472	fn a_schedule_holds_both_entries_and_the_read_epoch() {
473		assert_eq!(
474			schedule(6),
475			FeeSchedule {
476				older: OLDER,
477				newer: NEWER.unwrap(),
478				newer_epoch: 7,
479				read_epoch: 6,
480			}
481		);
482	}
483
484	#[test]
485	fn the_newer_fee_applies_from_newer_epoch() {
486		let schedule = schedule(0);
487		assert_eq!(schedule.fee_at(6), OLDER);
488		assert_eq!(schedule.fee_at(7), NEWER.unwrap());
489		assert_eq!(schedule.fee_at(8), NEWER.unwrap());
490		assert_eq!(schedule.fee_at(0), OLDER);
491		assert_eq!(schedule.fee_at(u64::MAX), NEWER.unwrap());
492	}
493
494	#[test]
495	fn fee_is_the_fee_at_the_read_epoch() {
496		for (read_epoch, fee) in [
497			(0, OLDER),
498			(6, OLDER),
499			(7, NEWER.unwrap()),
500			(8, NEWER.unwrap()),
501		] {
502			let schedule = schedule(read_epoch);
503			assert_eq!(schedule.fee(), fee, "read at {read_epoch}");
504			assert_eq!(schedule.fee(), schedule.fee_at(read_epoch));
505		}
506	}
507
508	/// SPL's `get_epoch_fee` picks the same entry at every epoch.
509	#[test]
510	fn fee_at_matches_get_epoch_fee() {
511		let config: TransferFeeConfig =
512			bytemuck::pod_read_unaligned(&fee_payload(None));
513		let schedule = FeeSchedule::of(&config, 0);
514		for epoch in [0, 5, 6, 7, 8, u64::MAX] {
515			assert_eq!(
516				schedule.fee_at(epoch),
517				mint_fee(config.get_epoch_fee(epoch)),
518				"epoch {epoch}"
519			);
520		}
521	}
522
523	#[test]
524	fn a_non_mint_account_type_is_malformed() {
525		let mut image = tlv_image(&[(FEE_TYPE, fee_payload(None))]);
526		// `AccountType::Account`.
527		image[ACCOUNT_TYPE_OFFSET] = 2;
528		assert_eq!(fee(&image), Err(ProgramError::InvalidAccountData));
529	}
530}