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