Skip to main content

sendfun_sdk/
transfer_fee.rs

1//! No `spl-token-2022`: it drags confidential transfers into a dep-free SDK.
2//!
3//! Cache a config only while [`TransferFeeConfig::authority`] is `None`.
4
5use std::fmt;
6
7use solana_address::Address;
8
9use crate::constants::{TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID};
10use crate::math::amm::MintFee;
11
12/// A Token-2022 mint of exactly this length carries no extensions.
13const MINT_BASE_LEN: usize = 82;
14
15/// Token-2022 pads the mint to the token-account length before this byte.
16const ACCOUNT_TYPE_OFFSET: usize = 165;
17
18const ACCOUNT_TYPE_MINT: u8 = 1;
19
20const TLV_START: usize = 166;
21
22const TLV_TYPE_LEN: usize = 2;
23
24const TLV_HEADER_LEN: usize = 4;
25
26const UNINITIALIZED_TYPE: u16 = 0;
27
28const TRANSFER_FEE_CONFIG_TYPE: u16 = 1;
29
30/// Two 32-byte authorities, `withheld_amount: u64`, then the older and newer
31/// 18-byte `TransferFee` entries.
32const TRANSFER_FEE_CONFIG_LEN: usize = 108;
33
34const CONFIG_AUTHORITY_OFFSET: usize = 0;
35
36const OLDER_FEE_OFFSET: usize = 72;
37
38const NEWER_FEE_OFFSET: usize = 90;
39
40const FEE_MAXIMUM_OFFSET: usize = 8;
41
42const FEE_BASIS_POINTS_OFFSET: usize = 16;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct TransferFeeEntry {
46	pub epoch: u64,
47	pub maximum_fee: u64,
48	pub basis_points: u16,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct TransferFeeConfig {
53	/// `None`: the schedule is frozen forever.
54	pub authority: Option<Address>,
55	pub older: TransferFeeEntry,
56	pub newer: TransferFeeEntry,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum TransferFeeDecodeError {
61	UnknownOwner,
62	Malformed,
63}
64
65impl fmt::Display for TransferFeeDecodeError {
66	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67		match self {
68			Self::UnknownOwner => {
69				write!(f, "Mint is owned by neither token program")
70			}
71			Self::Malformed => write!(f, "Malformed mint extension data"),
72		}
73	}
74}
75
76impl std::error::Error for TransferFeeDecodeError {}
77
78fn read_u16(payload: &[u8], offset: usize) -> Option<u16> {
79	let end = offset.checked_add(2)?;
80	let &[low, high] = payload.get(offset..end)? else {
81		return None;
82	};
83	Some(u16::from_le_bytes([low, high]))
84}
85
86fn read_u64(payload: &[u8], offset: usize) -> Option<u64> {
87	let end = offset.checked_add(8)?;
88	let bytes: [u8; 8] = payload.get(offset..end)?.try_into().ok()?;
89	Some(u64::from_le_bytes(bytes))
90}
91
92fn read_entry(payload: &[u8], offset: usize) -> Option<TransferFeeEntry> {
93	Some(TransferFeeEntry {
94		epoch: read_u64(payload, offset)?,
95		maximum_fee: read_u64(
96			payload,
97			offset.checked_add(FEE_MAXIMUM_OFFSET)?,
98		)?,
99		basis_points: read_u16(
100			payload,
101			offset.checked_add(FEE_BASIS_POINTS_OFFSET)?,
102		)?,
103	})
104}
105
106/// Exact length only: an oversized payload would parse its first 108 bytes.
107/// `None` means [`TransferFeeDecodeError::Malformed`], never "no fee".
108fn read_config(payload: &[u8]) -> Option<TransferFeeConfig> {
109	if payload.len() != TRANSFER_FEE_CONFIG_LEN {
110		return None;
111	}
112
113	let end = CONFIG_AUTHORITY_OFFSET.checked_add(32)?;
114	let authority: [u8; 32] =
115		payload.get(CONFIG_AUTHORITY_OFFSET..end)?.try_into().ok()?;
116
117	Some(TransferFeeConfig {
118		// `OptionalNonZeroPubkey` is `None` exactly when all 32 bytes are zero.
119		authority: (authority != [0; 32])
120			.then(|| Address::new_from_array(authority)),
121		older: read_entry(payload, OLDER_FEE_OFFSET)?,
122		newer: read_entry(payload, NEWER_FEE_OFFSET)?,
123	})
124}
125
126/// `(type, payload)` in account order; empty for an SPL mint. Every entry up to
127/// the first zero type, or a tail too short for one, must be whole.
128pub fn mint_extensions<'a>(
129	data: &'a [u8],
130	owner: &Address,
131) -> Result<Vec<(u16, &'a [u8])>, TransferFeeDecodeError> {
132	if owner == &TOKEN_PROGRAM_ID {
133		return Ok(Vec::new());
134	}
135	if owner != &TOKEN_2022_PROGRAM_ID {
136		return Err(TransferFeeDecodeError::UnknownOwner);
137	}
138	if data.len() == MINT_BASE_LEN {
139		return Ok(Vec::new());
140	}
141	if data.len() < TLV_START {
142		return Err(TransferFeeDecodeError::Malformed);
143	}
144	// A token account shares this TLV layout with different extension types.
145	if data.get(ACCOUNT_TYPE_OFFSET) != Some(&ACCOUNT_TYPE_MINT) {
146		return Err(TransferFeeDecodeError::Malformed);
147	}
148
149	let mut extensions = Vec::new();
150	let mut offset = TLV_START;
151	while offset < data.len() {
152		let type_end = offset
153			.checked_add(TLV_TYPE_LEN)
154			.ok_or(TransferFeeDecodeError::Malformed)?;
155		// Too short for a type, or a zero type: trailing slack, as SPL reads it.
156		let Some(&[type_low, type_high]) = data.get(offset..type_end) else {
157			break;
158		};
159		let extension_type = u16::from_le_bytes([type_low, type_high]);
160		if extension_type == UNINITIALIZED_TYPE {
161			break;
162		}
163
164		let header_end = offset
165			.checked_add(TLV_HEADER_LEN)
166			.ok_or(TransferFeeDecodeError::Malformed)?;
167		// A set type with a cut length is malformed, not the end of the list.
168		let Some(&[length_low, length_high]) = data.get(type_end..header_end)
169		else {
170			return Err(TransferFeeDecodeError::Malformed);
171		};
172
173		let length = usize::from(u16::from_le_bytes([length_low, length_high]));
174		let payload_end = header_end
175			.checked_add(length)
176			.ok_or(TransferFeeDecodeError::Malformed)?;
177		let payload = data
178			.get(header_end..payload_end)
179			.ok_or(TransferFeeDecodeError::Malformed)?;
180		extensions.push((extension_type, payload));
181		offset = payload_end;
182	}
183
184	Ok(extensions)
185}
186
187/// `Ok(None)` only without the extension. A 0 bps config stays `Some`: its
188/// rate can rise, and a leg grossed up as fee-free fails the program's bound.
189pub fn decode_transfer_fee_config(
190	data: &[u8],
191	owner: &Address,
192) -> Result<Option<TransferFeeConfig>, TransferFeeDecodeError> {
193	mint_extensions(data, owner)?
194		.into_iter()
195		.find(|(extension_type, _)| *extension_type == TRANSFER_FEE_CONFIG_TYPE)
196		.map(|(_, payload)| {
197			read_config(payload).ok_or(TransferFeeDecodeError::Malformed)
198		})
199		.transpose()
200}
201
202/// Mirrors SPL's `get_epoch_fee`.
203#[must_use]
204pub const fn transfer_fee_at_epoch(
205	config: &TransferFeeConfig,
206	epoch: u64,
207) -> MintFee {
208	let entry = if epoch >= config.newer.epoch {
209		&config.newer
210	} else {
211		&config.older
212	};
213
214	MintFee {
215		bps: entry.basis_points,
216		maximum_fee: entry.maximum_fee,
217	}
218}
219
220pub fn mint_fee_at_epoch(
221	data: &[u8],
222	owner: &Address,
223	epoch: u64,
224) -> Result<Option<MintFee>, TransferFeeDecodeError> {
225	Ok(decode_transfer_fee_config(data, owner)?
226		.map(|config| transfer_fee_at_epoch(&config, epoch)))
227}
228
229#[cfg(test)]
230mod tests {
231	use super::*;
232
233	const OLDER: TransferFeeEntry = TransferFeeEntry {
234		epoch: 5,
235		maximum_fee: 1_000,
236		basis_points: 100,
237	};
238
239	const NEWER: TransferFeeEntry = TransferFeeEntry {
240		epoch: 7,
241		maximum_fee: 2_000,
242		basis_points: 250,
243	};
244
245	const AUTHORITY: [u8; 32] = [9; 32];
246
247	/// `MetadataPointer` then `TokenMetadata`: what `create_token` writes.
248	fn other_extensions() -> Vec<(u16, Vec<u8>)> {
249		vec![(18, vec![0; 64]), (19, vec![0; 90])]
250	}
251
252	fn fee_payload(
253		authority: Option<[u8; 32]>,
254		older: TransferFeeEntry,
255		newer: TransferFeeEntry,
256	) -> Vec<u8> {
257		let mut payload = Vec::with_capacity(TRANSFER_FEE_CONFIG_LEN);
258		payload.extend_from_slice(&authority.unwrap_or([0; 32]));
259		// `withdraw_withheld_authority`, then `withheld_amount`.
260		payload.extend_from_slice(&[0; 32]);
261		payload.extend_from_slice(&0_u64.to_le_bytes());
262		for entry in [older, newer] {
263			payload.extend_from_slice(&entry.epoch.to_le_bytes());
264			payload.extend_from_slice(&entry.maximum_fee.to_le_bytes());
265			payload.extend_from_slice(&entry.basis_points.to_le_bytes());
266		}
267		payload
268	}
269
270	fn default_fee_payload() -> Vec<u8> {
271		fee_payload(Some(AUTHORITY), OLDER, NEWER)
272	}
273
274	fn tlv_image(entries: &[(u16, Vec<u8>)]) -> Vec<u8> {
275		let mut data = vec![0_u8; ACCOUNT_TYPE_OFFSET];
276		data.push(ACCOUNT_TYPE_MINT);
277		for (extension_type, payload) in entries {
278			data.extend_from_slice(&extension_type.to_le_bytes());
279			data.extend_from_slice(
280				&u16::try_from(payload.len()).unwrap().to_le_bytes(),
281			);
282			data.extend_from_slice(payload);
283		}
284		data
285	}
286
287	fn decode(
288		data: &[u8],
289	) -> Result<Option<TransferFeeConfig>, TransferFeeDecodeError> {
290		decode_transfer_fee_config(data, &TOKEN_2022_PROGRAM_ID)
291	}
292
293	fn expected() -> Option<TransferFeeConfig> {
294		Some(TransferFeeConfig {
295			authority: Some(Address::new_from_array(AUTHORITY)),
296			older: OLDER,
297			newer: NEWER,
298		})
299	}
300
301	#[test]
302	fn the_walk_finds_the_config_wherever_it_sits() {
303		let entry = (TRANSFER_FEE_CONFIG_TYPE, default_fee_payload());
304
305		let mut first = vec![entry.clone()];
306		first.extend(other_extensions());
307		assert_eq!(decode(&tlv_image(&first)), Ok(expected()));
308
309		let mut middle = other_extensions();
310		middle.insert(1, entry.clone());
311		assert_eq!(decode(&tlv_image(&middle)), Ok(expected()));
312
313		let mut last = other_extensions();
314		last.push(entry);
315		assert_eq!(decode(&tlv_image(&last)), Ok(expected()));
316	}
317
318	#[test]
319	fn a_revoked_authority_reads_as_none() {
320		let image = tlv_image(&[(
321			TRANSFER_FEE_CONFIG_TYPE,
322			fee_payload(None, OLDER, NEWER),
323		)]);
324
325		assert_eq!(
326			decode(&image),
327			Ok(Some(TransferFeeConfig {
328				authority: None,
329				older: OLDER,
330				newer: NEWER,
331			}))
332		);
333	}
334
335	#[test]
336	fn the_terminator_ends_the_walk() {
337		let mut image = tlv_image(&other_extensions());
338		image.extend(core::iter::repeat_n(0_u8, 512));
339
340		assert_eq!(decode(&image), Ok(None));
341	}
342
343	#[test]
344	fn a_region_ending_on_a_boundary_carries_no_config() {
345		assert_eq!(decode(&tlv_image(&other_extensions())), Ok(None));
346	}
347
348	#[test]
349	fn a_bare_mint_has_no_tlv_region_to_walk() {
350		assert_eq!(decode(&[0_u8; MINT_BASE_LEN]), Ok(None));
351	}
352
353	#[test]
354	fn a_truncated_header_is_malformed() {
355		let mut image = tlv_image(&other_extensions());
356		image.truncate(TLV_START + TLV_HEADER_LEN + 64 + 2);
357
358		assert_eq!(decode(&image), Err(TransferFeeDecodeError::Malformed));
359	}
360
361	#[test]
362	fn a_length_running_past_the_buffer_is_malformed() {
363		let mut image = tlv_image(&[(18, vec![0; 64])]);
364		image.truncate(TLV_START + TLV_HEADER_LEN + 32);
365
366		assert_eq!(decode(&image), Err(TransferFeeDecodeError::Malformed));
367	}
368
369	#[test]
370	fn a_config_payload_of_the_wrong_length_is_malformed() {
371		for length in [TRANSFER_FEE_CONFIG_LEN - 8, TRANSFER_FEE_CONFIG_LEN + 8]
372		{
373			let image =
374				tlv_image(&[(TRANSFER_FEE_CONFIG_TYPE, vec![0; length])]);
375			assert_eq!(
376				decode(&image),
377				Err(TransferFeeDecodeError::Malformed),
378				"payload length {length}"
379			);
380		}
381	}
382
383	#[test]
384	fn a_non_mint_account_type_is_malformed() {
385		let mut image =
386			tlv_image(&[(TRANSFER_FEE_CONFIG_TYPE, default_fee_payload())]);
387		image.splice(ACCOUNT_TYPE_OFFSET..TLV_START, core::iter::once(2_u8));
388
389		assert_eq!(decode(&image), Err(TransferFeeDecodeError::Malformed));
390	}
391
392	#[test]
393	fn a_length_short_of_the_tlv_region_is_malformed() {
394		for length in [MINT_BASE_LEN + 1, ACCOUNT_TYPE_OFFSET] {
395			assert_eq!(
396				decode(&vec![0_u8; length]),
397				Err(TransferFeeDecodeError::Malformed),
398				"account length {length}"
399			);
400		}
401	}
402
403	#[test]
404	fn an_unknown_owner_is_rejected() {
405		let image =
406			tlv_image(&[(TRANSFER_FEE_CONFIG_TYPE, default_fee_payload())]);
407
408		assert_eq!(
409			decode_transfer_fee_config(
410				&image,
411				&crate::constants::SYSTEM_PROGRAM_ID
412			),
413			Err(TransferFeeDecodeError::UnknownOwner)
414		);
415	}
416
417	#[test]
418	fn a_classic_owner_short_circuits_the_walk() {
419		let image =
420			tlv_image(&[(TRANSFER_FEE_CONFIG_TYPE, default_fee_payload())]);
421
422		assert_eq!(
423			decode_transfer_fee_config(&image, &TOKEN_PROGRAM_ID),
424			Ok(None)
425		);
426	}
427
428	/// Mainnet fixtures never change rate across epochs; this test does.
429	#[test]
430	fn mint_fee_at_epoch_reads_the_entry_for_the_epoch() {
431		let image =
432			tlv_image(&[(TRANSFER_FEE_CONFIG_TYPE, default_fee_payload())]);
433
434		assert_eq!(
435			mint_fee_at_epoch(&image, &TOKEN_2022_PROGRAM_ID, NEWER.epoch - 1),
436			Ok(Some(MintFee {
437				bps: OLDER.basis_points,
438				maximum_fee: OLDER.maximum_fee,
439			}))
440		);
441		assert_eq!(
442			mint_fee_at_epoch(&image, &TOKEN_2022_PROGRAM_ID, NEWER.epoch),
443			Ok(Some(MintFee {
444				bps: NEWER.basis_points,
445				maximum_fee: NEWER.maximum_fee,
446			}))
447		);
448	}
449
450	#[test]
451	fn mint_extensions_lists_every_entry_in_order() {
452		let mut entries = other_extensions();
453		entries.insert(1, (TRANSFER_FEE_CONFIG_TYPE, default_fee_payload()));
454		let mut image = tlv_image(&entries);
455		image.extend(core::iter::repeat_n(0_u8, 64));
456
457		let extensions =
458			mint_extensions(&image, &TOKEN_2022_PROGRAM_ID).unwrap();
459		let expected: Vec<(u16, &[u8])> = entries
460			.iter()
461			.map(|(extension_type, payload)| {
462				(*extension_type, payload.as_slice())
463			})
464			.collect();
465		assert_eq!(extensions, expected);
466		assert_eq!(mint_extensions(&image, &TOKEN_PROGRAM_ID), Ok(Vec::new()));
467	}
468
469	/// SPL's walk ends on a tail too short for a type, or on a zero type too
470	/// short for a length: a mint whose extensions total `Multisig::LEN` is
471	/// allocated two bytes past them.
472	#[test]
473	fn a_short_zero_tail_ends_the_walk() {
474		for tail in 1..TLV_HEADER_LEN {
475			let mut image =
476				tlv_image(&[(TRANSFER_FEE_CONFIG_TYPE, default_fee_payload())]);
477			image.extend(core::iter::repeat_n(0_u8, tail));
478
479			assert_eq!(decode(&image), Ok(expected()), "tail of {tail}");
480		}
481	}
482
483	/// Every entry must be whole, including those after the fee config.
484	#[test]
485	fn a_cut_entry_after_the_config_is_malformed() {
486		let mut image =
487			tlv_image(&[(TRANSFER_FEE_CONFIG_TYPE, default_fee_payload())]);
488		image.extend_from_slice(&[18, 0, 64, 0]);
489
490		assert_eq!(decode(&image), Err(TransferFeeDecodeError::Malformed));
491	}
492}