diff --git a/token/client/src/token.rs b/token/client/src/token.rs index a1b773b91dd..20cae87c1fe 100644 --- a/token/client/src/token.rs +++ b/token/client/src/token.rs @@ -43,8 +43,9 @@ use { ConfidentialTransferFeeConfig, }, cpi_guard, default_account_state, group_member_pointer, group_pointer, - interest_bearing_mint, memo_transfer, metadata_pointer, transfer_fee, transfer_hook, - BaseStateWithExtensions, Extension, ExtensionType, StateWithExtensionsOwned, + interest_bearing_mint, memo_transfer, metadata_pointer, scaled_ui_amount, transfer_fee, + transfer_hook, BaseStateWithExtensions, Extension, ExtensionType, + StateWithExtensionsOwned, }, instruction, offchain, solana_zk_sdk::{ @@ -188,6 +189,10 @@ pub enum ExtensionInitializationParams { authority: Option, member_address: Option, }, + ScaledUiAmountConfig { + authority: Option, + multiplier: f64, + }, } impl ExtensionInitializationParams { /// Get the extension type associated with the init params @@ -207,6 +212,7 @@ impl ExtensionInitializationParams { } Self::GroupPointer { .. } => ExtensionType::GroupPointer, Self::GroupMemberPointer { .. } => ExtensionType::GroupMemberPointer, + Self::ScaledUiAmountConfig { .. } => ExtensionType::ScaledUiAmount, } } /// Generate an appropriate initialization instruction for the given mint @@ -316,6 +322,15 @@ impl ExtensionInitializationParams { authority, member_address, ), + Self::ScaledUiAmountConfig { + authority, + multiplier, + } => scaled_ui_amount::instruction::initialize( + token_program_id, + mint, + authority, + multiplier, + ), } } } @@ -1805,6 +1820,31 @@ where .await } + /// Update multiplier + pub async fn update_multiplier( + &self, + authority: &Pubkey, + new_multiplier: f64, + new_multiplier_effective_timestamp: i64, + signing_keypairs: &S, + ) -> TokenResult { + let signing_pubkeys = signing_keypairs.pubkeys(); + let multisig_signers = self.get_multisig_signers(authority, &signing_pubkeys); + + self.process_ixs( + &[scaled_ui_amount::instruction::update_multiplier( + &self.program_id, + self.get_address(), + authority, + &multisig_signers, + new_multiplier, + new_multiplier_effective_timestamp, + )?], + signing_keypairs, + ) + .await + } + /// Update transfer hook program id pub async fn update_transfer_hook_program_id( &self, diff --git a/token/program-2022-test/tests/scaled_ui_amount.rs b/token/program-2022-test/tests/scaled_ui_amount.rs new file mode 100644 index 00000000000..062e6fafad8 --- /dev/null +++ b/token/program-2022-test/tests/scaled_ui_amount.rs @@ -0,0 +1,379 @@ +#![cfg(feature = "test-sbf")] + +mod program_test; +use { + program_test::{keypair_clone, TestContext, TokenContext}, + solana_program_test::{ + processor, + tokio::{self, sync::Mutex}, + ProgramTest, + }, + solana_sdk::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + instruction::{AccountMeta, Instruction, InstructionError}, + msg, + program::{get_return_data, invoke}, + program_error::ProgramError, + pubkey::Pubkey, + signature::Signer, + signer::keypair::Keypair, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_token_2022::{ + error::TokenError, + extension::{scaled_ui_amount::ScaledUiAmountConfig, BaseStateWithExtensions}, + instruction::{amount_to_ui_amount, ui_amount_to_amount, AuthorityType}, + processor::Processor, + }, + spl_token_client::token::{ExtensionInitializationParams, TokenError as TokenClientError}, + std::{convert::TryInto, sync::Arc}, +}; + +#[tokio::test] +async fn success_initialize() { + for (multiplier, authority) in [ + (f64::MIN_POSITIVE, None), + (f64::MAX, Some(Pubkey::new_unique())), + ] { + let mut context = TestContext::new().await; + context + .init_token_with_mint(vec![ExtensionInitializationParams::ScaledUiAmountConfig { + authority, + multiplier, + }]) + .await + .unwrap(); + let TokenContext { token, .. } = context.token_context.unwrap(); + + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(Option::::from(extension.authority), authority,); + assert_eq!(f64::from(extension.multiplier), multiplier); + assert_eq!(f64::from(extension.new_multiplier), multiplier); + assert_eq!(i64::from(extension.new_multiplier_effective_timestamp), 0); + } +} + +#[tokio::test] +async fn fail_initialize_with_interest_bearing() { + let authority = None; + let mut context = TestContext::new().await; + let err = context + .init_token_with_mint(vec![ + ExtensionInitializationParams::ScaledUiAmountConfig { + authority, + multiplier: 1.0, + }, + ExtensionInitializationParams::InterestBearingConfig { + rate_authority: None, + rate: 0, + }, + ]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 3, + InstructionError::Custom(TokenError::InvalidExtensionCombination as u32) + ) + ))) + ); +} + +#[tokio::test] +async fn fail_initialize_with_bad_multiplier() { + let mut context = TestContext::new().await; + let err = context + .init_token_with_mint(vec![ExtensionInitializationParams::ScaledUiAmountConfig { + authority: None, + multiplier: 0.0, + }]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 1, + InstructionError::Custom(TokenError::InvalidScale as u32) + ) + ))) + ); +} + +#[tokio::test] +async fn update_multiplier() { + let authority = Keypair::new(); + let initial_multiplier = 5.0; + let mut context = TestContext::new().await; + context + .init_token_with_mint(vec![ExtensionInitializationParams::ScaledUiAmountConfig { + authority: Some(authority.pubkey()), + multiplier: initial_multiplier, + }]) + .await + .unwrap(); + let TokenContext { token, .. } = context.token_context.take().unwrap(); + + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(f64::from(extension.multiplier), initial_multiplier); + assert_eq!(f64::from(extension.new_multiplier), initial_multiplier); + + // correct + let new_multiplier = 10.0; + token + .update_multiplier(&authority.pubkey(), new_multiplier, 0, &[&authority]) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(f64::from(extension.multiplier), new_multiplier); + assert_eq!(f64::from(extension.new_multiplier), new_multiplier); + assert_eq!(i64::from(extension.new_multiplier_effective_timestamp), 0); + + // fail, bad number + let err = token + .update_multiplier(&authority.pubkey(), f64::INFINITY, 0, &[&authority]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::InvalidScale as u32) + ) + ))) + ); + + // correct in the future + let newest_multiplier = 100.0; + token + .update_multiplier( + &authority.pubkey(), + newest_multiplier, + i64::MAX, + &[&authority], + ) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(f64::from(extension.multiplier), new_multiplier); + assert_eq!(f64::from(extension.new_multiplier), newest_multiplier); + assert_eq!( + i64::from(extension.new_multiplier_effective_timestamp), + i64::MAX + ); + + // wrong signer + let wrong_signer = Keypair::new(); + let err = token + .update_multiplier(&wrong_signer.pubkey(), 1.0, 0, &[&wrong_signer]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::OwnerMismatch as u32) + ) + ))) + ); +} + +#[tokio::test] +async fn set_authority() { + let authority = Keypair::new(); + let initial_multiplier = 500.0; + let mut context = TestContext::new().await; + context + .init_token_with_mint(vec![ExtensionInitializationParams::ScaledUiAmountConfig { + authority: Some(authority.pubkey()), + multiplier: initial_multiplier, + }]) + .await + .unwrap(); + let TokenContext { token, .. } = context.token_context.take().unwrap(); + + // success + let new_authority = Keypair::new(); + token + .set_authority( + token.get_address(), + &authority.pubkey(), + Some(&new_authority.pubkey()), + AuthorityType::ScaledUiAmount, + &[&authority], + ) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!( + extension.authority, + Some(new_authority.pubkey()).try_into().unwrap(), + ); + token + .update_multiplier(&new_authority.pubkey(), 10.0, 0, &[&new_authority]) + .await + .unwrap(); + let err = token + .update_multiplier(&authority.pubkey(), 100.0, 0, &[&authority]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::OwnerMismatch as u32) + ) + ))) + ); + + // set to none + token + .set_authority( + token.get_address(), + &new_authority.pubkey(), + None, + AuthorityType::ScaledUiAmount, + &[&new_authority], + ) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(extension.authority, None.try_into().unwrap(),); + + // now all fail + let err = token + .update_multiplier(&new_authority.pubkey(), 50.0, 0, &[&new_authority]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::NoAuthorityExists as u32) + ) + ))) + ); + let err = token + .update_multiplier(&authority.pubkey(), 5.5, 0, &[&authority]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::NoAuthorityExists as u32) + ) + ))) + ); +} + +// test program to CPI into token to get ui amounts +fn process_instruction( + _program_id: &Pubkey, + accounts: &[AccountInfo], + _input: &[u8], +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let mint_info = next_account_info(account_info_iter)?; + let token_program = next_account_info(account_info_iter)?; + // 10 tokens, with 9 decimal places + let test_amount = 10_000_000_000; + // "10" as an amount should be smaller than test_amount due to interest + invoke( + &ui_amount_to_amount(token_program.key, mint_info.key, "50")?, + &[mint_info.clone(), token_program.clone()], + )?; + let (_, return_data) = get_return_data().unwrap(); + let amount = u64::from_le_bytes(return_data[0..8].try_into().unwrap()); + msg!("amount: {}", amount); + if amount != test_amount { + return Err(ProgramError::InvalidInstructionData); + } + + // test_amount as a UI amount should be larger due to interest + invoke( + &amount_to_ui_amount(token_program.key, mint_info.key, test_amount)?, + &[mint_info.clone(), token_program.clone()], + )?; + let (_, return_data) = get_return_data().unwrap(); + let ui_amount = String::from_utf8(return_data).unwrap(); + msg!("ui amount: {}", ui_amount); + let float_ui_amount = ui_amount.parse::().unwrap(); + if float_ui_amount != 50.0 { + return Err(ProgramError::InvalidInstructionData); + } + Ok(()) +} + +#[tokio::test] +async fn amount_conversions() { + let authority = Keypair::new(); + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(false); + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(Processor::process), + ); + let program_id = Pubkey::new_unique(); + program_test.add_program( + "ui_amount_to_amount", + program_id, + processor!(process_instruction), + ); + + let context = program_test.start_with_context().await; + let payer = keypair_clone(&context.payer); + let last_blockhash = context.last_blockhash; + let context = Arc::new(Mutex::new(context)); + let mut context = TestContext { + context, + token_context: None, + }; + let initial_multiplier = 5.0; + context + .init_token_with_mint(vec![ExtensionInitializationParams::ScaledUiAmountConfig { + authority: Some(authority.pubkey()), + multiplier: initial_multiplier, + }]) + .await + .unwrap(); + let TokenContext { token, .. } = context.token_context.take().unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[Instruction { + program_id, + accounts: vec![ + AccountMeta::new_readonly(*token.get_address(), false), + AccountMeta::new_readonly(spl_token_2022::id(), false), + ], + data: vec![], + }], + Some(&payer.pubkey()), + &[&payer], + last_blockhash, + ); + context + .context + .lock() + .await + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} diff --git a/token/program-2022/src/error.rs b/token/program-2022/src/error.rs index 832ccf65715..77f20cf5877 100644 --- a/token/program-2022/src/error.rs +++ b/token/program-2022/src/error.rs @@ -263,6 +263,9 @@ pub enum TokenError { /// Withdraw / Deposit not allowed for confidential-mint-burn #[error("Withdraw / Deposit not allowed for confidential-mint-burn")] IllegalMintBurnConversion, + /// Invalid scale for scaled ui amount + #[error("Invalid scale for scaled ui amount")] + InvalidScale, } impl From for ProgramError { fn from(e: TokenError) -> Self { @@ -453,6 +456,9 @@ impl PrintProgramError for TokenError { TokenError::IllegalMintBurnConversion => { msg!("Conversions from normal to confidential token balance and vice versa are illegal if the confidential-mint-burn extension is enabled") } + TokenError::InvalidScale => { + msg!("Invalid scale for scaled ui amount") + } } } } diff --git a/token/program-2022/src/extension/mod.rs b/token/program-2022/src/extension/mod.rs index 76bdb092c8e..d3f45977041 100644 --- a/token/program-2022/src/extension/mod.rs +++ b/token/program-2022/src/extension/mod.rs @@ -22,6 +22,7 @@ use { mint_close_authority::MintCloseAuthority, non_transferable::{NonTransferable, NonTransferableAccount}, permanent_delegate::PermanentDelegate, + scaled_ui_amount::ScaledUiAmountConfig, transfer_fee::{TransferFeeAmount, TransferFeeConfig}, transfer_hook::{TransferHook, TransferHookAccount}, }, @@ -76,6 +77,8 @@ pub mod non_transferable; pub mod permanent_delegate; /// Utility to reallocate token accounts pub mod reallocate; +/// Scaled UI Amount extension +pub mod scaled_ui_amount; /// Token-group extension pub mod token_group; /// Token-metadata extension @@ -1109,6 +1112,8 @@ pub enum ExtensionType { TokenGroupMember, /// Mint allowing the minting and burning of confidential tokens ConfidentialMintBurn, + /// Tokens whose UI amount is scaled by a given amount + ScaledUiAmount, /// Test variable-length mint extension #[cfg(test)] @@ -1191,6 +1196,7 @@ impl ExtensionType { ExtensionType::GroupMemberPointer => pod_get_packed_len::(), ExtensionType::TokenGroupMember => pod_get_packed_len::(), ExtensionType::ConfidentialMintBurn => pod_get_packed_len::(), + ExtensionType::ScaledUiAmount => pod_get_packed_len::(), #[cfg(test)] ExtensionType::AccountPaddingTest => pod_get_packed_len::(), #[cfg(test)] @@ -1255,7 +1261,8 @@ impl ExtensionType { | ExtensionType::TokenGroup | ExtensionType::GroupMemberPointer | ExtensionType::ConfidentialMintBurn - | ExtensionType::TokenGroupMember => AccountType::Mint, + | ExtensionType::TokenGroupMember + | ExtensionType::ScaledUiAmount => AccountType::Mint, ExtensionType::ImmutableOwner | ExtensionType::TransferFeeAmount | ExtensionType::ConfidentialTransferAccount @@ -1307,6 +1314,8 @@ impl ExtensionType { let mut confidential_transfer_mint = false; let mut confidential_transfer_fee_config = false; let mut confidential_mint_burn = false; + let mut interest_bearing = false; + let mut scaled_ui_amount = false; for extension_type in mint_extension_types { match extension_type { @@ -1316,6 +1325,8 @@ impl ExtensionType { confidential_transfer_fee_config = true } ExtensionType::ConfidentialMintBurn => confidential_mint_burn = true, + ExtensionType::InterestBearingConfig => interest_bearing = true, + ExtensionType::ScaledUiAmount => scaled_ui_amount = true, _ => (), } } @@ -1333,6 +1344,10 @@ impl ExtensionType { return Err(TokenError::InvalidExtensionCombination); } + if scaled_ui_amount && interest_bearing { + return Err(TokenError::InvalidExtensionCombination); + } + Ok(()) } } diff --git a/token/program-2022/src/extension/scaled_ui_amount/instruction.rs b/token/program-2022/src/extension/scaled_ui_amount/instruction.rs new file mode 100644 index 00000000000..cb939f6a675 --- /dev/null +++ b/token/program-2022/src/extension/scaled_ui_amount/instruction.rs @@ -0,0 +1,143 @@ +#[cfg(feature = "serde-traits")] +use serde::{Deserialize, Serialize}; +use { + crate::{ + check_program_account, + extension::scaled_ui_amount::{PodF64, UnixTimestamp}, + instruction::{encode_instruction, TokenInstruction}, + }, + bytemuck::{Pod, Zeroable}, + num_enum::{IntoPrimitive, TryFromPrimitive}, + solana_program::{ + instruction::{AccountMeta, Instruction}, + program_error::ProgramError, + pubkey::Pubkey, + }, + spl_pod::optional_keys::OptionalNonZeroPubkey, + std::convert::TryInto, +}; + +/// Interesting-bearing mint extension instructions +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Debug, PartialEq, IntoPrimitive, TryFromPrimitive)] +#[repr(u8)] +pub enum ScaledUiAmountMintInstruction { + /// Initialize a new mint with scaled UI amounts. + /// + /// Fails if the mint has already been initialized, so must be called before + /// `InitializeMint`. + /// + /// Fails if the multiplier is less than or equal to 0 or if it's + /// [subnormal](https://en.wikipedia.org/wiki/Subnormal_number). + /// + /// The mint must have exactly enough space allocated for the base mint (82 + /// bytes), plus 83 bytes of padding, 1 byte reserved for the account type, + /// then space required for this extension, plus any others. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[writable]` The mint to initialize. + /// + /// Data expected by this instruction: + /// `crate::extension::scaled_ui_amount::instruction::InitializeInstructionData` + Initialize, + /// Update the multiplier. Only supported for mints that include the + /// `ScaledUiAmount` extension. + /// + /// Fails if the multiplier is less than or equal to 0 or if it's + /// [subnormal](https://en.wikipedia.org/wiki/Subnormal_number). + /// + /// The authority provides a new multiplier and a unix timestamp on which + /// it should take effect. If the timestamp is before the current time, + /// immediately sets the multiplier. + /// + /// Accounts expected by this instruction: + /// + /// * Single authority + /// 0. `[writable]` The mint. + /// 1. `[signer]` The multiplier authority. + /// + /// * Multisignature authority + /// 0. `[writable]` The mint. + /// 1. `[]` The mint's multisignature multiplier authority. + /// 2. `..2+M` `[signer]` M signer accounts. + /// + /// Data expected by this instruction: + /// `crate::extension::scaled_ui_amount::instruction::UpdateMultiplierInstructionData` + UpdateMultiplier, +} + +/// Data expected by `ScaledUiAmountMint::Initialize` +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct InitializeInstructionData { + /// The public key for the account that can update the multiplier + pub authority: OptionalNonZeroPubkey, + /// The initial multiplier + pub multiplier: PodF64, +} + +/// Data expected by `ScaledUiAmountMint::UpdateMultiplier` +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct UpdateMultiplierInstructionData { + /// The new multiplier + pub multiplier: PodF64, + /// Timestamp at which the new multiplier will take effect + pub effective_timestamp: UnixTimestamp, +} + +/// Create an `Initialize` instruction +pub fn initialize( + token_program_id: &Pubkey, + mint: &Pubkey, + authority: Option, + multiplier: f64, +) -> Result { + check_program_account(token_program_id)?; + let accounts = vec![AccountMeta::new(*mint, false)]; + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::ScaledUiAmountExtension, + ScaledUiAmountMintInstruction::Initialize, + &InitializeInstructionData { + authority: authority.try_into()?, + multiplier: multiplier.into(), + }, + )) +} + +/// Create an `UpdateMultiplier` instruction +pub fn update_multiplier( + token_program_id: &Pubkey, + mint: &Pubkey, + authority: &Pubkey, + signers: &[&Pubkey], + multiplier: f64, + effective_timestamp: i64, +) -> Result { + check_program_account(token_program_id)?; + let mut accounts = vec![ + AccountMeta::new(*mint, false), + AccountMeta::new_readonly(*authority, signers.is_empty()), + ]; + for signer_pubkey in signers.iter() { + accounts.push(AccountMeta::new_readonly(**signer_pubkey, true)); + } + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::ScaledUiAmountExtension, + ScaledUiAmountMintInstruction::UpdateMultiplier, + &UpdateMultiplierInstructionData { + effective_timestamp: effective_timestamp.into(), + multiplier: multiplier.into(), + }, + )) +} diff --git a/token/program-2022/src/extension/scaled_ui_amount/mod.rs b/token/program-2022/src/extension/scaled_ui_amount/mod.rs new file mode 100644 index 00000000000..839caf585c5 --- /dev/null +++ b/token/program-2022/src/extension/scaled_ui_amount/mod.rs @@ -0,0 +1,341 @@ +#[cfg(feature = "serde-traits")] +use serde::{Deserialize, Serialize}; +use { + crate::extension::{Extension, ExtensionType}, + bytemuck::{Pod, Zeroable}, + solana_program::program_error::ProgramError, + spl_pod::{optional_keys::OptionalNonZeroPubkey, primitives::PodI64}, +}; + +/// Scaled UI amount extension instructions +pub mod instruction; + +/// Scaled UI amount extension processor +pub mod processor; + +/// `UnixTimestamp` expressed with an alignment-independent type +pub type UnixTimestamp = PodI64; + +/// `f64` type that can be used in `Pod`s +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(from = "f64", into = "f64"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] +#[repr(transparent)] +pub struct PodF64(pub [u8; 8]); +impl PodF64 { + fn from_primitive(n: f64) -> Self { + Self(n.to_le_bytes()) + } +} +impl From for PodF64 { + fn from(n: f64) -> Self { + Self::from_primitive(n) + } +} +impl From for f64 { + fn from(pod: PodF64) -> Self { + Self::from_le_bytes(pod.0) + } +} + +/// Scaled UI amount extension data for mints +#[repr(C)] +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] +pub struct ScaledUiAmountConfig { + /// Authority that can set the scaling amount and authority + pub authority: OptionalNonZeroPubkey, + /// Amount to multiply raw amounts by, outside of the decimal + pub multiplier: PodF64, + /// Unix timestamp at which `new_multiplier` comes into effective + pub new_multiplier_effective_timestamp: UnixTimestamp, + /// Next multiplier, once `new_multiplier_effective_timestamp` is reached + pub new_multiplier: PodF64, +} +impl ScaledUiAmountConfig { + fn total_multiplier(&self, decimals: u8, unix_timestamp: i64) -> f64 { + let multiplier = if unix_timestamp >= self.new_multiplier_effective_timestamp.into() { + self.new_multiplier + } else { + self.multiplier + }; + f64::from(multiplier) / 10_f64.powi(decimals as i32) + } + + /// Convert a raw amount to its UI representation using the given decimals + /// field. Excess zeroes or unneeded decimal point are trimmed. + pub fn amount_to_ui_amount( + &self, + amount: u64, + decimals: u8, + unix_timestamp: i64, + ) -> Option { + let scaled_amount = (amount as f64) * self.total_multiplier(decimals, unix_timestamp); + Some(scaled_amount.to_string()) + } + + /// Try to convert a UI representation of a token amount to its raw amount + /// using the given decimals field + pub fn try_ui_amount_into_amount( + &self, + ui_amount: &str, + decimals: u8, + unix_timestamp: i64, + ) -> Result { + let scaled_amount = ui_amount + .parse::() + .map_err(|_| ProgramError::InvalidArgument)?; + let amount = scaled_amount / self.total_multiplier(decimals, unix_timestamp); + if amount > (u64::MAX as f64) || amount < (u64::MIN as f64) || amount.is_nan() { + Err(ProgramError::InvalidArgument) + } else { + // this is important, if you round earlier, you'll get wrong "inf" + // answers + Ok(amount.round() as u64) + } + } +} +impl Extension for ScaledUiAmountConfig { + const TYPE: ExtensionType = ExtensionType::ScaledUiAmount; +} + +#[cfg(test)] +mod tests { + use {super::*, proptest::prelude::*}; + + const TEST_DECIMALS: u8 = 2; + + #[test] + fn multiplier_choice() { + let multiplier = 5.0; + let new_multiplier = 10.0; + let new_multiplier_effective_timestamp = 1; + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: PodF64::from(multiplier), + new_multiplier: PodF64::from(new_multiplier), + new_multiplier_effective_timestamp: UnixTimestamp::from( + new_multiplier_effective_timestamp, + ), + }; + assert_eq!( + config.total_multiplier(0, new_multiplier_effective_timestamp), + new_multiplier + ); + assert_eq!( + config.total_multiplier(0, new_multiplier_effective_timestamp - 1), + multiplier + ); + assert_eq!(config.total_multiplier(0, 0), multiplier); + assert_eq!(config.total_multiplier(0, i64::MIN), multiplier); + assert_eq!(config.total_multiplier(0, i64::MAX), new_multiplier); + } + + #[test] + fn specific_amount_to_ui_amount() { + // 5x + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: PodF64::from(5.0), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + let ui_amount = config.amount_to_ui_amount(1, 0, 0).unwrap(); + assert_eq!(ui_amount, "5"); + // with 1 decimal place + let ui_amount = config.amount_to_ui_amount(1, 1, 0).unwrap(); + assert_eq!(ui_amount, "0.5"); + // with 10 decimal places + let ui_amount = config.amount_to_ui_amount(1, 10, 0).unwrap(); + assert_eq!(ui_amount, "0.0000000005"); + + // huge amount with 10 decimal places + let ui_amount = config.amount_to_ui_amount(10_000_000_000, 10, 0).unwrap(); + assert_eq!(ui_amount, "5"); + + // huge values + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: PodF64::from(f64::MAX), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + let ui_amount = config.amount_to_ui_amount(u64::MAX, 0, 0).unwrap(); + assert_eq!(ui_amount, "inf"); + } + + #[test] + fn specific_ui_amount_to_amount() { + // constant 5x + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: 5.0.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + let amount = config.try_ui_amount_into_amount("5.0", 0, 0).unwrap(); + assert_eq!(1, amount); + // with 1 decimal place + let amount = config + .try_ui_amount_into_amount("0.500000000", 1, 0) + .unwrap(); + assert_eq!(amount, 1); + // with 10 decimal places + let amount = config + .try_ui_amount_into_amount("0.00000000050000000000000000", 10, 0) + .unwrap(); + assert_eq!(amount, 1); + + // huge amount with 10 decimal places + let amount = config + .try_ui_amount_into_amount("5.0000000000000000", 10, 0) + .unwrap(); + assert_eq!(amount, 10_000_000_000); + + // huge values + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: 5.0.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + let amount = config + .try_ui_amount_into_amount("92233720368547758075", 0, 0) + .unwrap(); + assert_eq!(amount, u64::MAX); + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: f64::MAX.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + // scientific notation "e" + let amount = config + .try_ui_amount_into_amount("1.7976931348623157e308", 0, 0) + .unwrap(); + assert_eq!(amount, 1); + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: 9.745314011399998e288.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + let amount = config + .try_ui_amount_into_amount("1.7976931348623157e308", 0, 0) + .unwrap(); + assert_eq!(amount, u64::MAX); + // scientific notation "E" + let amount = config + .try_ui_amount_into_amount("1.7976931348623157E308", 0, 0) + .unwrap(); + assert_eq!(amount, u64::MAX); + + // this is unfortunate, but underflows can happen due to floats + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: 1.0.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + assert_eq!( + u64::MAX, + config + .try_ui_amount_into_amount("18446744073709551616", 0, 0) + .unwrap() // u64::MAX + 1 + ); + + // overflow u64 fail + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: 0.1.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + assert_eq!( + Err(ProgramError::InvalidArgument), + config.try_ui_amount_into_amount("18446744073709551615", 0, 0) // u64::MAX + 1 + ); + + for fail_ui_amount in ["-0.0000000000000000000001", "inf", "-inf", "NaN"] { + assert_eq!( + Err(ProgramError::InvalidArgument), + config.try_ui_amount_into_amount(fail_ui_amount, 0, 0) + ); + } + } + + #[test] + fn specific_amount_to_ui_amount_no_scale() { + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: 1.0.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + for (amount, expected) in [(23, "0.23"), (110, "1.1"), (4200, "42"), (0, "0")] { + let ui_amount = config + .amount_to_ui_amount(amount, TEST_DECIMALS, 0) + .unwrap(); + assert_eq!(ui_amount, expected); + } + } + + #[test] + fn specific_ui_amount_to_amount_no_scale() { + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: 1.0.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + for (ui_amount, expected) in [ + ("0.23", 23), + ("0.20", 20), + ("0.2000", 20), + (".2", 20), + ("1.1", 110), + ("1.10", 110), + ("42", 4200), + ("42.", 4200), + ("0", 0), + ] { + let amount = config + .try_ui_amount_into_amount(ui_amount, TEST_DECIMALS, 0) + .unwrap(); + assert_eq!(expected, amount); + } + + // this is invalid with normal mints, but rounding for this mint makes it ok + let amount = config + .try_ui_amount_into_amount("0.111", TEST_DECIMALS, 0) + .unwrap(); + assert_eq!(11, amount); + + // fail if invalid ui_amount passed in + for ui_amount in ["", ".", "0.t"] { + assert_eq!( + Err(ProgramError::InvalidArgument), + config.try_ui_amount_into_amount(ui_amount, TEST_DECIMALS, 0), + ); + } + } + + proptest! { + #[test] + fn amount_to_ui_amount( + scale in 0f64..=f64::MAX, + amount in 0..=u64::MAX, + decimals in 0u8..20u8, + ) { + let config = ScaledUiAmountConfig { + authority: OptionalNonZeroPubkey::default(), + multiplier: scale.into(), + new_multiplier_effective_timestamp: UnixTimestamp::from(1), + ..Default::default() + }; + let ui_amount = config.amount_to_ui_amount(amount, decimals, 0); + assert!(ui_amount.is_some()); + } + } +} diff --git a/token/program-2022/src/extension/scaled_ui_amount/processor.rs b/token/program-2022/src/extension/scaled_ui_amount/processor.rs new file mode 100644 index 00000000000..1199a64c804 --- /dev/null +++ b/token/program-2022/src/extension/scaled_ui_amount/processor.rs @@ -0,0 +1,126 @@ +use { + crate::{ + check_program_account, + error::TokenError, + extension::{ + scaled_ui_amount::{ + instruction::{ + InitializeInstructionData, ScaledUiAmountMintInstruction, + UpdateMultiplierInstructionData, + }, + PodF64, ScaledUiAmountConfig, UnixTimestamp, + }, + BaseStateWithExtensionsMut, PodStateWithExtensionsMut, + }, + instruction::{decode_instruction_data, decode_instruction_type}, + pod::PodMint, + processor::Processor, + }, + solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, + sysvar::Sysvar, + }, + spl_pod::optional_keys::OptionalNonZeroPubkey, +}; + +fn try_validate_multiplier(multiplier: &PodF64) -> ProgramResult { + let float_multiplier = f64::from(*multiplier); + if float_multiplier.is_sign_positive() && float_multiplier.is_normal() { + Ok(()) + } else { + Err(TokenError::InvalidScale.into()) + } +} + +fn process_initialize( + _program_id: &Pubkey, + accounts: &[AccountInfo], + authority: &OptionalNonZeroPubkey, + multiplier: &PodF64, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let mint_account_info = next_account_info(account_info_iter)?; + let mut mint_data = mint_account_info.data.borrow_mut(); + let mut mint = PodStateWithExtensionsMut::::unpack_uninitialized(&mut mint_data)?; + + let extension = mint.init_extension::(true)?; + extension.authority = *authority; + try_validate_multiplier(multiplier)?; + extension.multiplier = *multiplier; + extension.new_multiplier_effective_timestamp = 0.into(); + extension.new_multiplier = *multiplier; + Ok(()) +} + +fn process_update_multiplier( + program_id: &Pubkey, + accounts: &[AccountInfo], + new_multiplier: &PodF64, + effective_timestamp: &UnixTimestamp, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let mint_account_info = next_account_info(account_info_iter)?; + let owner_info = next_account_info(account_info_iter)?; + let owner_info_data_len = owner_info.data_len(); + + let mut mint_data = mint_account_info.data.borrow_mut(); + let mut mint = PodStateWithExtensionsMut::::unpack(&mut mint_data)?; + let extension = mint.get_extension_mut::()?; + let authority = + Option::::from(extension.authority).ok_or(TokenError::NoAuthorityExists)?; + + Processor::validate_owner( + program_id, + &authority, + owner_info, + owner_info_data_len, + account_info_iter.as_slice(), + )?; + + try_validate_multiplier(new_multiplier)?; + let clock = Clock::get()?; + extension.new_multiplier = *new_multiplier; + let int_effective_timestamp = i64::from(*effective_timestamp); + // just floor it to 0 + if int_effective_timestamp < 0 { + extension.new_multiplier_effective_timestamp = 0.into(); + } else { + extension.new_multiplier_effective_timestamp = *effective_timestamp; + } + // if the new effective timestamp has already passed, also set the old + // multiplier, just to be clear + if clock.unix_timestamp >= int_effective_timestamp { + extension.multiplier = *new_multiplier; + } + Ok(()) +} + +pub(crate) fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + check_program_account(program_id)?; + match decode_instruction_type(input)? { + ScaledUiAmountMintInstruction::Initialize => { + msg!("ScaledUiAmountMintInstruction::Initialize"); + let InitializeInstructionData { + authority, + multiplier, + } = decode_instruction_data(input)?; + process_initialize(program_id, accounts, authority, multiplier) + } + ScaledUiAmountMintInstruction::UpdateMultiplier => { + msg!("ScaledUiAmountMintInstruction::UpdateScale"); + let UpdateMultiplierInstructionData { + effective_timestamp, + multiplier, + } = decode_instruction_data(input)?; + process_update_multiplier(program_id, accounts, multiplier, effective_timestamp) + } + } +} diff --git a/token/program-2022/src/instruction.rs b/token/program-2022/src/instruction.rs index 1706271f5bb..c36c666ec58 100644 --- a/token/program-2022/src/instruction.rs +++ b/token/program-2022/src/instruction.rs @@ -712,6 +712,9 @@ pub enum TokenInstruction<'a> { /// Instruction prefix for instructions to the confidential-mint-burn /// extension ConfidentialMintBurnExtension, + /// Instruction prefix for instructions to the scaled ui amount + /// extension + ScaledUiAmountExtension, } impl<'a> TokenInstruction<'a> { /// Unpacks a byte buffer into a @@ -852,6 +855,7 @@ impl<'a> TokenInstruction<'a> { 40 => Self::GroupPointerExtension, 41 => Self::GroupMemberPointerExtension, 42 => Self::ConfidentialMintBurnExtension, + 43 => Self::ScaledUiAmountExtension, _ => return Err(TokenError::InvalidInstruction.into()), }) } @@ -1026,6 +1030,9 @@ impl<'a> TokenInstruction<'a> { &Self::ConfidentialMintBurnExtension => { buf.push(42); } + &Self::ScaledUiAmountExtension => { + buf.push(43); + } }; buf } @@ -1123,6 +1130,8 @@ pub enum AuthorityType { GroupPointer, /// Authority to set the group member address GroupMemberPointer, + /// Authority to set the UI amount scale + ScaledUiAmount, } impl AuthorityType { @@ -1143,6 +1152,7 @@ impl AuthorityType { AuthorityType::MetadataPointer => 12, AuthorityType::GroupPointer => 13, AuthorityType::GroupMemberPointer => 14, + AuthorityType::ScaledUiAmount => 15, } } @@ -1163,6 +1173,7 @@ impl AuthorityType { 12 => Ok(AuthorityType::MetadataPointer), 13 => Ok(AuthorityType::GroupPointer), 14 => Ok(AuthorityType::GroupMemberPointer), + 15 => Ok(AuthorityType::ScaledUiAmount), _ => Err(TokenError::InvalidInstruction.into()), } } diff --git a/token/program-2022/src/pod_instruction.rs b/token/program-2022/src/pod_instruction.rs index a08f8b68a7a..05da1e42d4d 100644 --- a/token/program-2022/src/pod_instruction.rs +++ b/token/program-2022/src/pod_instruction.rs @@ -115,6 +115,7 @@ pub(crate) enum PodTokenInstruction { GroupPointerExtension, GroupMemberPointerExtension, ConfidentialMintBurnExtension, + ScaledUiAmountExtension, } fn unpack_pubkey_option(input: &[u8]) -> Result, ProgramError> { diff --git a/token/program-2022/src/processor.rs b/token/program-2022/src/processor.rs index ee219bbef05..99a813e4775 100644 --- a/token/program-2022/src/processor.rs +++ b/token/program-2022/src/processor.rs @@ -21,7 +21,9 @@ use { mint_close_authority::MintCloseAuthority, non_transferable::{NonTransferable, NonTransferableAccount}, permanent_delegate::{get_permanent_delegate, PermanentDelegate}, - reallocate, token_group, token_metadata, + reallocate, + scaled_ui_amount::{self, ScaledUiAmountConfig}, + token_group, token_metadata, transfer_fee::{self, TransferFeeAmount, TransferFeeConfig}, transfer_hook::{self, TransferHook, TransferHookAccount}, AccountType, BaseStateWithExtensions, BaseStateWithExtensionsMut, ExtensionType, @@ -906,6 +908,19 @@ impl Processor { )?; extension.authority = new_authority.try_into()?; } + AuthorityType::ScaledUiAmount => { + let extension = mint.get_extension_mut::()?; + let maybe_authority: Option = extension.authority.into(); + let authority = maybe_authority.ok_or(TokenError::AuthorityTypeNotSupported)?; + Self::validate_owner( + program_id, + &authority, + authority_info, + authority_info_data_len, + account_info_iter.as_slice(), + )?; + extension.authority = new_authority.try_into()?; + } _ => { return Err(TokenError::AuthorityTypeNotSupported.into()); } @@ -1372,6 +1387,11 @@ impl Processor { extension .amount_to_ui_amount(amount, mint.base.decimals, unix_timestamp) .ok_or(ProgramError::InvalidArgument)? + } else if let Ok(extension) = mint.get_extension::() { + let unix_timestamp = Clock::get()?.unix_timestamp; + extension + .amount_to_ui_amount(amount, mint.base.decimals, unix_timestamp) + .ok_or(ProgramError::InvalidArgument)? } else { crate::amount_to_ui_amount_string_trimmed(amount, mint.base.decimals) }; @@ -1393,6 +1413,9 @@ impl Processor { let amount = if let Ok(extension) = mint.get_extension::() { let unix_timestamp = Clock::get()?.unix_timestamp; extension.try_ui_amount_into_amount(ui_amount, mint.base.decimals, unix_timestamp)? + } else if let Ok(extension) = mint.get_extension::() { + let unix_timestamp = Clock::get()?.unix_timestamp; + extension.try_ui_amount_into_amount(ui_amount, mint.base.decimals, unix_timestamp)? } else { crate::try_ui_amount_into_amount(ui_amount.to_string(), mint.base.decimals)? }; @@ -1813,6 +1836,14 @@ impl Processor { &input[1..], ) } + PodTokenInstruction::ScaledUiAmountExtension => { + msg!("Instruction: ScaledUiAmountExtension"); + scaled_ui_amount::processor::process_instruction( + program_id, + accounts, + &input[1..], + ) + } } } else if let Ok(instruction) = TokenMetadataInstruction::unpack(input) { token_metadata::processor::process_instruction(program_id, accounts, instruction)